From ae6c59b28f11b556fa978ccc123ab4ef061e3b54 Mon Sep 17 00:00:00 2001 From: user Date: Sat, 28 Feb 2026 16:13:59 -0800 Subject: [PATCH] docs: update poller dispatcher, PR state machine, agent chaining (closes #7) --- AUTOMATED_DEV.md | 248 ++++++++++++++++-------------- OPENCLAW_TRICKS.md | 366 ++++++++++++++++++++++++++++++--------------- 2 files changed, 381 insertions(+), 233 deletions(-) diff --git a/AUTOMATED_DEV.md b/AUTOMATED_DEV.md index 7774d48..e7ea705 100644 --- a/AUTOMATED_DEV.md +++ b/AUTOMATED_DEV.md @@ -74,103 +74,108 @@ back to issues. ### PR State Machine -Once a PR exists, it enters a finite state machine tracked by Gitea labels and -issue assignments. Labels represent the current state; the assignment field -represents who's responsible for the next action. +Once a PR exists, it enters a finite state machine tracked by Gitea labels. Each +PR has exactly one state label at a time, plus a `bot` label indicating it's the +agent's turn to act. #### States (Gitea Labels) -| Label | Color | Meaning | -| -------------- | ------ | ------------------------------------------------- | -| `needs-rebase` | red | PR has merge conflicts or is behind main | -| `needs-checks` | orange | `make check` does not pass cleanly | -| `needs-review` | yellow | Code review not yet done | -| `needs-rework` | purple | Code review found issues that need fixing | -| `merge-ready` | green | All checks pass, reviewed, rebased, conflict-free | +| Label | Color | Meaning | +| -------------- | ------ | --------------------------------------------- | +| `needs-review` | yellow | Code pushed, `docker build .` passes, awaiting review | +| `needs-rework` | purple | Code review found issues that need fixing | +| `merge-ready` | green | Reviewed clean, build passes, ready for human | -#### Transitions +Earlier iterations included `needs-rebase` and `needs-checks` states, but we +eliminated them. Rebasing is handled inline by workers and reviewers (they +rebase onto the target branch as part of their normal work). And `docker build .` +is the only check — it's run by workers before pushing and by reviewers before +approving. There's no separate "checks" phase. + +#### The `bot` Label + Assignment Model + +The `bot` label signals that an issue or PR is the agent's turn to act. The +assignment field tracks who is actively working on it: + +- **`bot` label + unassigned** = work available, poller dispatches an agent +- **`bot` label + assigned to agent** = actively being worked +- **No `bot` label** = not the agent's turn (either human's turn or done) + +The notification poller assigns the agent account to the issue at dispatch time, +before the agent session even starts. This prevents race conditions — by the +time a second poller scan runs, the issue is already assigned and gets skipped. + +When the agent finishes its step and spawns the next agent, it unassigns itself +first (releasing the lock). The next agent's first action is to verify it's the +only one working on the issue by checking comments for duplicate work. + +At chain-end (`merge-ready`): the agent assigns the human and removes the `bot` +label. The human's PR inbox contains only PRs that are genuinely ready to merge. + +#### Agent Chaining — No Self-Review + +Each step in the pipeline is handled by a separate, isolated agent session. +Agents spawn the next agent in the chain via `openclaw cron add --session +isolated`. This enforces a critical rule: **the agent that wrote the code never +reviews it.** + +The chain looks like this: ``` -New PR created - │ - ▼ -[needs-rebase] ──rebase onto main──▶ [needs-checks] - ▲ │ - │ run make check - │ (main updated, │ - │ conflicts) ┌─────────────┴──────────────┐ - │ │ │ - │ passes fails - │ │ │ - │ ▼ ▼ - │ [needs-review] [needs-checks] - │ │ (fix code, re-run) - │ code review - │ │ - │ ┌─────────┴──────────┐ - │ │ │ - │ approved issues found - │ │ │ - │ ▼ ▼ - │ [merge-ready] [needs-rework] - │ │ │ - │ assign human fix issues - │ │ - │ ▼ - └───────────────────────────── [needs-rebase] - (restart cycle) +Worker agent (writes/fixes code) + → docker build . → push → label needs-review + → unassign self → spawn reviewer agent → STOP + +Reviewer agent (reviews code it didn't write) + → read diff + referenced issues → review + → PASS: rebase if needed → docker build . → label merge-ready + → assign human → remove bot label → STOP + → FAIL: comment findings → label needs-rework + → unassign self → spawn worker agent → STOP ``` -The cycle can repeat multiple times: rebase → check → review → rework → rebase → -check → review → rework → ... until the PR is clean. Each iteration typically -addresses a smaller set of issues until everything converges. +The cycle repeats (worker → reviewer → worker → reviewer → ...) until the +reviewer approves. Each agent is a fresh session with no memory of previous +iterations — it reads the issue comments and PR diff to understand context. -#### Assignment Rules +#### TOCTOU Protection -- **PR in any state except `merge-ready`** → assigned to the agent. It's the - agent's job to drive it forward through the state machine. -- **PR reaches `merge-ready`** → assigned to the human. This is the ONLY time a - PR should land in the human's queue. -- **Human requests changes during review** → PR moves back to `needs-rework`, - reassigned to agent. +Just before changing labels or assignments, agents re-read all comments and +current labels via the API. If the state changed since they started (another +agent already acted), they report the conflict and stop. This prevents stale +agents from overwriting fresh state. -This means the human's PR inbox contains only PRs that are genuinely ready to -merge — no half-finished work, no failing CI, no merge conflicts. Everything -else is the agent's problem. +#### Race Detection + +If an agent starts and finds its work was already done (e.g., a reviewer sees a +review was already posted, or a worker sees a PR was already created), it +reports to the status channel and stops. #### The Loop in Practice -A typical PR might go through this cycle: +A typical PR goes through this cycle: -1. Agent creates PR, labels `needs-rebase` -2. Agent rebases onto main → labels `needs-checks` -3. Agent runs `make check` — lint fails → fixes lint, pushes → back to - `needs-rebase` (new commit) -4. Agent rebases → `needs-checks` → runs checks → passes → `needs-review` -5. Agent does code review — finds a missing error check → `needs-rework` -6. Agent fixes the error check, pushes → `needs-rebase` -7. Agent rebases → `needs-checks` → passes → `needs-review` -8. Agent reviews — looks good → `merge-ready` -9. Agent assigns to human -10. Human reviews, merges +1. Worker agent creates PR, runs `docker build .`, labels `needs-review` +2. Worker spawns reviewer agent +3. Reviewer reads diff — finds a missing error check → labels `needs-rework` +4. Reviewer spawns worker agent +5. Worker fixes the error check, rebases, runs `docker build .`, labels + `needs-review` +6. Worker spawns reviewer agent +7. Reviewer reads diff — looks good → rebases → `docker build .` → labels + `merge-ready`, assigns human +8. Human reviews, merges -Steps 1-9 happen without human involvement. The human sees a clean, reviewed, -passing PR ready for a final look. +Steps 1-7 happen without human involvement. Each step is a separate agent +session that spawns the next one. -#### Automated Sweep +#### Safety Net -A periodic cron job (every 4 hours) scans all open PRs across all repos: - -- **No label** → classify into the correct state -- **`needs-rebase`** → spawn agent to rebase -- **`needs-checks`** → spawn agent to run checks and fix failures -- **`needs-review`** → spawn agent to do code review -- **`needs-rework`** → spawn agent to fix review feedback -- **`merge-ready`** → verify still true (main may have updated since), ensure - assigned to human - -This catches PRs that fell through the cracks — an agent session that timed out -mid-rework, a rebase that became necessary when main moved forward, etc. +The notification poller runs a periodic scan (every 2 minutes) of all watched +repos for issues/PRs with the `bot` label that are unassigned. This catches +broken chains — if an agent crashes or times out without spawning the next agent, +the poller will eventually re-dispatch. A 30-minute cooldown prevents duplicate +dispatches during normal operation. #### Why Labels + Assignments @@ -263,26 +268,45 @@ A practical setup: - **DM with agent** — Private conversation, sitreps, sensitive commands - **Project-specific channels** — For coordination with external collaborators -### The Notification Poller +### The Notification Poller + Dispatcher Because the agent can't see Gitea webhooks in Mattermost (bot-to-bot visibility -issue), we built a lightweight Python script that polls the Gitea notifications -API every 2 seconds and wakes the agent via OpenClaw's `/hooks/wake` endpoint -when new notifications arrive. +issue), we built a Python script that both polls and dispatches. It polls the +Gitea notifications API every 15 seconds, triages each notification (checking +@-mentions and assignment), marks them as read, and spawns one isolated agent +session per actionable item via `openclaw cron add --session isolated`. + +The poller also runs a secondary **label scan** every 2 minutes, checking all +watched repos for open issues/PRs with the `bot` label that are unassigned +(meaning they need work but no agent has claimed them yet). This catches cases +where the agent chain broke — an agent timed out or crashed without spawning the +next one. Key design decisions: -- **The poller never marks notifications as read.** That's the agent's job after - processing. Prevents the poller and agent from racing. -- **Tracks notification IDs, not counts.** Only fires on genuinely new - notifications, not re-reads of existing ones. -- **The wake message tells the agent to route output to Gitea/Mattermost, not - DM.** Prevents chatty notification processing from disturbing the human. -- **Zero dependencies.** Python stdlib only (`urllib`, `json`, `time`). Runs - anywhere. +- **The poller IS the dispatcher.** No flag files, no heartbeat dependency. The + poller triages notifications and spawns agents directly. +- **Marks notifications as read immediately.** Prevents re-dispatch on the next + poll cycle. +- **Assigns the agent account at dispatch time.** Before spawning the agent + session, the poller assigns the bot user to the issue via API. This prevents + race conditions — subsequent scans skip assigned issues. +- **Dispatched issues are tracked in a persistent JSON file.** Survives poller + restarts. Entries auto-prune after 1 hour. +- **30-minute re-dispatch cooldown.** The poller won't re-dispatch for the same + issue within 30 minutes, even if it appears unassigned again. +- **Concurrency cap.** The poller checks how many agents are currently running + and defers dispatch if the cap is reached. +- **Stale agent reaper.** Kills agent sessions that have been running longer + than 10 minutes (the `--timeout-seconds` flag isn't always enforced). +- **`bot` label + `merge-ready` skip.** The label scan skips issues that are + already labeled `merge-ready` — those are in the human's court. +- **Zero dependencies.** Python stdlib only. Runs anywhere. + +Response time: ~15-30 seconds from notification to agent starting work. Full source code is available in -[OPENCLAW_TRICKS.md](OPENCLAW_TRICKS.md#the-gitea-notification-poller). +[OPENCLAW_TRICKS.md](OPENCLAW_TRICKS.md#gitea-integration--notification-polling). ## CI: Gitea Actions @@ -371,42 +395,34 @@ Everything gets a production URL with automatic TLS via Traefik. Putting it all together, the development lifecycle looks like this: ``` -1. Issue filed in Gitea (by human or agent) +1. Human labels issue with `bot` (or agent files issue) ↓ -2. Agent picks up the issue (via notification poller) +2. Poller detects `bot` label + unassigned → assigns agent → spawns worker ↓ -3. Agent posts "starting work on #N" to Mattermost #git +3. Worker agent clones repo, writes code, runs `docker build .` ↓ -4. Agent (or sub-agent) creates branch, writes code, pushes +4. Worker creates PR "(closes #N)", labels `needs-review` ↓ -5. Gitea webhook fires → #git shows the push +5. Worker spawns reviewer agent → stops ↓ -6. CI runs docker build → passes or fails +6. Reviewer agent reads diff + referenced issues → reviews ↓ -7. Agent creates PR "(closes #N)" +7a. Review PASS → reviewer rebases if needed → `docker build .` + → labels `merge-ready` → assigns human → removes `bot` ↓ -8. Gitea webhook fires → #git shows the PR +7b. Review FAIL → reviewer labels `needs-rework` + → spawns worker agent → back to step 3 ↓ -9. Agent reviews code, runs make check locally, verifies +8. Human reviews, merges ↓ -10. Agent assigns PR to human when all checks pass +9. Gitea webhook fires → µPaaS deploys to production ↓ -11. Human reviews, requests changes or approves - ↓ -12. If changes requested → agent reworks, back to step 6 - ↓ -13. Human merges PR - ↓ -14. Gitea webhook fires → µPaaS deploys to production - ↓ -15. Gitea webhook fires → #git shows the merge - ↓ -16. Site/service is live on production URL +10. Site/service is live ``` -Steps 2-10 can happen without any human involvement. The human's role is reduced -to: review the PR, approve or request changes, merge. Everything else is -automated. +Steps 2-7 happen without any human involvement, driven by agent-to-agent +chaining. The human's role is reduced to: label the issue, review the final PR, +merge. Everything else is automated. ### Observability diff --git a/OPENCLAW_TRICKS.md b/OPENCLAW_TRICKS.md index 5475fe4..cdeb37b 100644 --- a/OPENCLAW_TRICKS.md +++ b/OPENCLAW_TRICKS.md @@ -197,33 +197,46 @@ OpenClaw runs on a Mac Studio on a home LAN. The solution: a Python script that both polls and dispatches. It polls the Gitea notifications API every 15 seconds, triages each notification (checking -assignment and @-mentions), marks them as read, and spawns one isolated agent +@-mentions and assignments), marks them as read, and spawns one isolated agent session per actionable item via `openclaw cron add --session isolated`. -The poller also runs a secondary **assignment scan** every 2 minutes, checking -all watched repos for open issues/PRs assigned to the bot that were recently -updated and still need a response. This catches cases where notifications aren't -generated (e.g. self-assignment, API-created issues). +The poller also runs a secondary **label scan** every 2 minutes, checking all +watched repos for open issues/PRs with the `bot` label that are unassigned. This +catches cases where the agent chain broke — an agent timed out or crashed +without spawning the next agent. It also picks up newly-labeled issues that +didn't trigger a notification. Key design decisions: - **The poller IS the dispatcher.** No flag files, no heartbeat dependency. The poller triages notifications and spawns agents directly. -- **Marks notifications as read immediately.** Each notification is marked read - as it's processed, preventing re-dispatch on the next poll. -- **One agent per issue.** Each spawned agent gets a `SCOPE` instruction - limiting it to one specific issue/PR. Agents post results as Gitea comments, - not DMs. -- **Dedup tracking.** An in-memory `dispatched_issues` set prevents spawning - multiple agents for the same issue within one poller lifetime. -- **`--no-deliver` instead of `--announce`.** Agents report via Gitea API - directly. The `--announce` flag on isolated sessions had delivery failures. -- **Assignment scan filters by recency.** Only issues updated in the last 5 - minutes are considered, preventing re-dispatch for stale assigned issues. -- **Zero dependencies.** Just Python stdlib. Runs anywhere. +- **Marks notifications as read immediately.** Prevents re-dispatch on the next + poll cycle. +- **Assigns the bot user at dispatch time.** Before spawning the agent, the + poller assigns the bot account to the issue via API. This prevents race + conditions — subsequent scans skip assigned issues. The spawned agent doesn't + need to claim ownership; it's already claimed. +- **Persistent dispatch tracking.** Dispatched issues are tracked in a JSON + file on disk (not just in memory), surviving poller restarts. Entries + auto-prune after 1 hour. +- **30-minute re-dispatch cooldown.** Safety net for broken agent chains. Normal + operation uses agent-to-agent chaining (each agent spawns the next), so the + poller only re-dispatches if the chain breaks. +- **Concurrency cap.** The poller checks how many agents are currently running + (`openclaw cron list`) and defers dispatch if the cap is reached. +- **Stale agent reaper.** Each scan cycle, kills agent sessions running longer + than 10 minutes. The `--timeout-seconds` flag isn't always enforced by + OpenClaw, so the poller handles cleanup itself. +- **`merge-ready` skip.** The label scan skips issues already labeled + `merge-ready` — those are in the human's court. +- **Template-based prompts.** The poller reads two workspace files (a dispatch + header with `{{variable}}` placeholders, and a workflow rules document), + concatenates them, substitutes variables, and passes the result as the + agent's `--message`. This keeps all instructions in version-controlled + workspace files with a single source of truth. +- **Zero dependencies.** Python stdlib only. Runs anywhere. -Response time: ~15–60s from notification to agent comment (vs ~30 min with the -old heartbeat approach). +Response time: ~15–30s from notification to agent starting work. ```python #!/usr/bin/env python3 @@ -231,20 +244,25 @@ old heartbeat approach). Gitea notification poller + dispatcher. Two polling loops: -1. Notification-based: detects new notifications (mentions, assignments by - other users) and dispatches agents for actionable ones. -2. Assignment-based: periodically checks for open issues/PRs assigned to - the bot that have no recent bot comment. Catches cases where - notifications aren't generated (e.g. self-assignment, API-created issues). +1. Notification-based: detects new @-mentions and assignments, dispatches + agents for actionable notifications. +2. Label-based: periodically scans for issues/PRs with the 'bot' label + that are unassigned (available for work). Catches broken agent chains + and newly-labeled issues. + +The poller assigns the bot user to the issue BEFORE spawning the agent, +preventing race conditions where multiple scans dispatch for the same issue. Required env vars: GITEA_URL - Gitea instance URL GITEA_TOKEN - Gitea API token Optional env vars: - POLL_DELAY - Delay between polls in seconds (default: 15) - COOLDOWN - Minimum seconds between dispatches (default: 30) - ASSIGNMENT_INTERVAL - Seconds between assignment scans (default: 120) + POLL_DELAY - Seconds between notification polls (default: 15) + COOLDOWN - Seconds between dispatch batches (default: 30) + BOT_SCAN_INTERVAL - Seconds between label scans (default: 120) + MAX_CONCURRENT_AGENTS - Max simultaneous agents (default: 10) + REAP_AGE_SECONDS - Kill agents older than this (default: 600) OPENCLAW_BIN - Path to openclaw binary """ @@ -260,18 +278,50 @@ GITEA_URL = os.environ.get("GITEA_URL", "").rstrip("/") GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "") POLL_DELAY = int(os.environ.get("POLL_DELAY", "15")) COOLDOWN = int(os.environ.get("COOLDOWN", "30")) -ASSIGNMENT_INTERVAL = int(os.environ.get("ASSIGNMENT_INTERVAL", "120")) -OPENCLAW_BIN = os.environ.get("OPENCLAW_BIN", "/opt/homebrew/bin/openclaw") -BOT_USER = "clawbot" # Change to your bot's Gitea username +BOT_SCAN_INTERVAL = int(os.environ.get("BOT_SCAN_INTERVAL", "120")) +MAX_CONCURRENT_AGENTS = int(os.environ.get("MAX_CONCURRENT_AGENTS", "10")) +REAP_AGE_SECONDS = int(os.environ.get("REAP_AGE_SECONDS", "600")) +REDISPATCH_COOLDOWN = 1800 # 30 min safety net for broken agent chains +OPENCLAW_BIN = os.environ.get("OPENCLAW_BIN", "openclaw") +BOT_USER = os.environ.get("BOT_USER", "clawbot") -# Repos to scan for assigned issues +WORKSPACE = os.path.expanduser("~/.openclaw/workspace") +DISPATCH_HEADER = os.path.join( + WORKSPACE, "taskprompts", "how-to-handle-gitea-notifications.md" +) +WORKFLOW_DOC = os.path.join( + WORKSPACE, "taskprompts", "how-to-work-on-a-gitea-issue-or-pr.md" +) +DISPATCH_STATE_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), ".dispatch-state.json" +) + +# Repos to watch for bot-labeled issues WATCHED_REPOS = [ # "org/repo1", # "org/repo2", ] -# Track dispatched issues to prevent duplicates -dispatched_issues = set() +# Dispatch tracking (persisted to disk) +dispatched_issues: dict[str, float] = {} + + +def _load_dispatch_state() -> dict[str, float]: + try: + with open(DISPATCH_STATE_PATH) as f: + state = json.load(f) + now = time.time() + return {k: v for k, v in state.items() if now - v < 3600} + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def _save_dispatch_state(): + try: + with open(DISPATCH_STATE_PATH, "w") as f: + json.dump(dispatched_issues, f) + except OSError as e: + print(f"WARN: Could not save dispatch state: {e}", file=sys.stderr) def gitea_api(method, path, data=None): @@ -290,17 +340,91 @@ def gitea_api(method, path, data=None): return None -def needs_bot_response(repo_full, issue_number): - """True if the bot is NOT the author of the most recent comment.""" - comments = gitea_api("GET", f"/repos/{repo_full}/issues/{issue_number}/comments") - if comments and len(comments) > 0: - if comments[-1].get("user", {}).get("login") == BOT_USER: - return False - return True +def load_template() -> str: + """Load dispatch header + workflow doc, concatenated.""" + parts = [] + for path in [DISPATCH_HEADER, WORKFLOW_DOC]: + try: + with open(path) as f: + parts.append(f.read()) + except FileNotFoundError: + print(f"ERROR: File not found: {path}", file=sys.stderr) + sys.exit(1) + return "\n\n---\n\n".join(parts) + + +def render_template(template, repo_full, issue_number, title, + subject_type, reason): + return ( + template + .replace("{{repo_full}}", repo_full) + .replace("{{issue_number}}", str(issue_number)) + .replace("{{title}}", title) + .replace("{{subject_type}}", subject_type) + .replace("{{reason}}", reason) + .replace("{{gitea_url}}", GITEA_URL) + .replace("{{gitea_token}}", GITEA_TOKEN) + .replace("{{openclaw_bin}}", OPENCLAW_BIN) + .replace("{{bot_user}}", BOT_USER) + # Add your own variables here (e.g. git_channel) + ) + + +def count_running_agents() -> int: + try: + result = subprocess.run( + [OPENCLAW_BIN, "cron", "list"], + capture_output=True, text=True, timeout=10, + ) + return sum(1 for line in result.stdout.splitlines() + if "running" in line or "idle" in line) + except Exception: + return 0 + + +def spawn_agent(template, repo_full, issue_number, title, + subject_type, reason): + dispatch_key = f"{repo_full}#{issue_number}" + last = dispatched_issues.get(dispatch_key) + if last and (time.time() - last) < REDISPATCH_COOLDOWN: + return + + if count_running_agents() >= MAX_CONCURRENT_AGENTS: + print(f" → Concurrency limit reached, deferring {dispatch_key}", + flush=True) + return + + dispatched_issues[dispatch_key] = time.time() + + # Assign bot user immediately to prevent races + gitea_api("PATCH", f"/repos/{repo_full}/issues/{issue_number}", + {"assignees": [BOT_USER]}) + + repo_short = repo_full.split("/")[-1] + job_name = f"gitea-{repo_short}-{issue_number}-{int(time.time())}" + msg = render_template(template, repo_full, issue_number, title, + subject_type, reason) + + try: + result = subprocess.run( + [OPENCLAW_BIN, "cron", "add", + "--name", job_name, "--at", "1s", + "--message", msg, "--delete-after-run", + "--session", "isolated", "--no-deliver", + "--thinking", "low", "--timeout-seconds", "300"], + capture_output=True, text=True, timeout=15, + ) + if result.returncode == 0: + _save_dispatch_state() + else: + dispatched_issues.pop(dispatch_key, None) + except Exception as e: + print(f"Spawn error: {e}", file=sys.stderr, flush=True) + dispatched_issues.pop(dispatch_key, None) def is_actionable(notif): - """Returns (actionable, reason, issue_number).""" + """Check if a notification warrants spawning an agent.""" subject = notif.get("subject", {}) repo = notif.get("repository", {}) repo_full = repo.get("full_name", "") @@ -313,68 +437,88 @@ def is_actionable(notif): if not issue: return False, "couldn't fetch issue", number - assignees = [a.get("login") for a in (issue.get("assignees") or [])] - if BOT_USER in assignees: - if needs_bot_response(repo_full, number): - return True, f"assigned to {BOT_USER}", number - return False, "assigned but already responded", number - - issue_body = issue.get("body", "") or "" - if f"@{BOT_USER}" in issue_body and issue.get("user", {}).get("login") != BOT_USER: - if needs_bot_response(repo_full, number): - return True, f"@-mentioned in body", number - - comments = gitea_api("GET", f"/repos/{repo_full}/issues/{number}/comments") + # Check for @-mentions in the latest comment + comments = gitea_api( + "GET", f"/repos/{repo_full}/issues/{number}/comments" + ) if comments: last = comments[-1] if last.get("user", {}).get("login") == BOT_USER: return False, "own comment is latest", number if f"@{BOT_USER}" in (last.get("body") or ""): - return True, f"@-mentioned in comment", number + return True, "@-mentioned in comment", number - return False, "not mentioned or assigned", number + # Check for @-mention in issue body + body = issue.get("body", "") or "" + if f"@{BOT_USER}" in body: + return True, "@-mentioned in body", number + + return False, "not mentioned", number -def spawn_agent(repo_full, issue_number, title, subject_type, reason): - dispatch_key = f"{repo_full}#{issue_number}" - if dispatch_key in dispatched_issues: - return - dispatched_issues.add(dispatch_key) +def scan_bot_labeled(template): + """Scan for issues/PRs with 'bot' label that are unassigned.""" + for repo_full in WATCHED_REPOS: + for issue_type in ["issues", "pulls"]: + items = gitea_api( + "GET", + f"/repos/{repo_full}/issues?state=open&type={issue_type}" + f"&labels=bot&sort=updated&limit=10", + ) or [] + for item in items: + number = str(item["number"]) + dispatch_key = f"{repo_full}#{number}" - repo_short = repo_full.split("/")[-1] - job_name = f"gitea-{repo_short}-{issue_number}-{int(time.time())}" - msg = ( - f"Gitea: {reason} on {subject_type} #{issue_number} " - f"'{title}' in {repo_full}.\n" - f"API: {GITEA_URL}/api/v1 | Token: {GITEA_TOKEN}\n" - f"SCOPE: Only {subject_type} #{issue_number} in {repo_full}.\n" - f"Read all comments, do the work, post results as Gitea comments." - ) - try: - subprocess.run( - [OPENCLAW_BIN, "cron", "add", - "--name", job_name, "--at", "1s", - "--message", msg, "--delete-after-run", - "--session", "isolated", "--no-deliver", - "--thinking", "low", "--timeout-seconds", "300"], - capture_output=True, text=True, timeout=15, - ) - except Exception as e: - print(f"Spawn error: {e}", file=sys.stderr, flush=True) - dispatched_issues.discard(dispatch_key) + last = dispatched_issues.get(dispatch_key) + if last and (time.time() - last) < REDISPATCH_COOLDOWN: + continue + + assignees = [ + a.get("login", "") for a in item.get("assignees") or [] + ] + if BOT_USER in assignees: + continue + + labels = [ + l.get("name", "") for l in item.get("labels") or [] + ] + if "merge-ready" in labels: + continue + + kind = "PR" if issue_type == "pulls" else "issue" + spawn_agent( + template, repo_full, number, + item.get("title", "")[:60], + "pull" if issue_type == "pulls" else "issue", + "bot label, unassigned", + ) def main(): - print(f"Poller started (poll={POLL_DELAY}s, cooldown={COOLDOWN}s)", flush=True) - seen_ids = set(n["id"] for n in (gitea_api("GET", "/notifications?status-types=unread") or [])) + global dispatched_issues + dispatched_issues = _load_dispatch_state() + + if not GITEA_URL or not GITEA_TOKEN: + print("ERROR: GITEA_URL and GITEA_TOKEN required", file=sys.stderr) + sys.exit(1) + + template = load_template() + print(f"Poller started (poll={POLL_DELAY}s, cooldown={COOLDOWN}s, " + f"bot_scan={BOT_SCAN_INTERVAL}s, repos={len(WATCHED_REPOS)})", + flush=True) + + seen_ids = set( + n["id"] for n in + (gitea_api("GET", "/notifications?status-types=unread") or []) + ) last_dispatch = 0 - last_assign_scan = 0 + last_bot_scan = 0 while True: time.sleep(POLL_DELAY) now = time.time() - # Notification polling + # --- Notification polling --- notifs = gitea_api("GET", "/notifications?status-types=unread") or [] current_ids = {n["id"] for n in notifs} new_ids = current_ids - seen_ids @@ -388,28 +532,14 @@ def main(): repo = n["repository"]["full_name"] title = n["subject"]["title"][:60] stype = n["subject"].get("type", "").lower() - spawn_agent(repo, num, title, stype, reason) + spawn_agent(template, repo, num, title, stype, reason) last_dispatch = now seen_ids = current_ids - # Assignment scan (less frequent) - if now - last_assign_scan >= ASSIGNMENT_INTERVAL: - for repo in WATCHED_REPOS: - for itype in ["issues", "pulls"]: - items = gitea_api("GET", - f"/repos/{repo}/issues?state=open&type={itype}" - f"&assignee={BOT_USER}&sort=updated&limit=10") or [] - for item in items: - num = str(item["number"]) - if f"{repo}#{num}" in dispatched_issues: - continue - # Only recently updated items (5 min) - # ... add is_recently_updated() check here - if needs_bot_response(repo, num): - spawn_agent(repo, num, item["title"][:60], - "pull" if itype == "pulls" else "issue", - f"assigned to {BOT_USER}") - last_assign_scan = now + # --- Bot label scan (less frequent) --- + if now - last_bot_scan >= BOT_SCAN_INTERVAL: + scan_bot_labeled(template) + last_bot_scan = now if __name__ == "__main__": @@ -754,25 +884,27 @@ From REPO_POLICIES.md and our operational experience: #### The PR Pipeline -Our agent follows a strict PR lifecycle: +Our agent follows a strict PR lifecycle using agent-to-agent chaining. Each step +is handled by a separate, isolated agent session — the agent that writes code +never reviews it: ```markdown ## PR pipeline (every PR, no exceptions) -1. **Review/rework loop**: code review → rework → re-review → repeat until clean -2. **Check/rework loop**: `make check` + `docker build .` → rework → re-check → - repeat until clean -3. Only after BOTH loops pass with zero issues: assign to human +Worker agent → docker build . → push → label needs-review → spawn reviewer +Reviewer agent → review diff → PASS: docker build . → label merge-ready + → FAIL: label needs-rework → spawn worker +Repeat until reviewer approves. -- "Passes checks" ≠ "ready for human" +- docker build . is the ONLY authoritative check (runs make check inside) - Never weaken tests/linters. Fix the code. - Pre-existing failures are YOUR problem. Fix them as part of your PR. ``` -The agent doesn't just create a PR and hand it off — it drives the PR through -review, rework, and verification until it's genuinely ready. A PR assigned to -the human means: all checks pass, code reviewed, review feedback addressed, -rebased against main, no conflicts. Anything less is the agent's open task. +The agent chain doesn't just create a PR and hand it off — it drives the PR +through review, rework, and verification until it's genuinely ready. A PR +assigned to the human means: build passes, code reviewed by a separate agent, +review feedback addressed, rebased. Anything less is still in the agent chain. #### New Repo Bootstrap @@ -1624,12 +1756,12 @@ For complex coding tasks, spawn isolated sub-agents. ### Sub-Agent PR Quality Gate (MANDATORY) -- `make check` must pass with ZERO failures. No exceptions. +- `docker build .` must pass. This is identical to CI and the only + authoritative check. No exceptions. - Pre-existing failures are YOUR problem. Fix them as part of your PR. - NEVER modify linter config to make checks pass. Fix the code. -- Every PR must include full `make check` output - Rebase before and after committing -- Never self-review +- Never self-review — each agent spawns a separate agent for review ``` ---