1 Commits

Author SHA1 Message Date
clawbot
c30a280d6e update Gitea poller docs to dispatcher architecture (closes #4)
Some checks failed
check / check (push) Failing after 11s
2026-02-28 06:30:25 -08:00
2 changed files with 364 additions and 385 deletions

View File

@@ -74,108 +74,103 @@ back to issues.
### PR State Machine
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.
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.
#### States (Gitea Labels)
| 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 |
| 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 |
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:
#### Transitions
```
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
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)
```
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.
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.
#### TOCTOU Protection
#### Assignment Rules
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.
- **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.
#### 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.
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.
#### The Loop in Practice
A typical PR goes through this cycle:
A typical PR might go through this cycle:
1. Worker agent creates PR, runs `docker build .`, labels `needs-review`
2. Worker spawns reviewer agent
3. Reviewer reads difffinds 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
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
Steps 1-7 happen without human involvement. Each step is a separate agent
session that spawns the next one.
Steps 1-9 happen without human involvement. The human sees a clean, reviewed,
passing PR ready for a final look.
#### Safety Net
#### Automated Sweep
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.
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.
#### Why Labels + Assignments
@@ -268,45 +263,26 @@ A practical setup:
- **DM with agent** — Private conversation, sitreps, sensitive commands
- **Project-specific channels** — For coordination with external collaborators
### The Notification Poller + Dispatcher
### The Notification Poller
Because the agent can't see Gitea webhooks in Mattermost (bot-to-bot visibility
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.
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.
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.** 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.
- **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.
Full source code is available in
[OPENCLAW_TRICKS.md](OPENCLAW_TRICKS.md#gitea-integration--notification-polling).
[OPENCLAW_TRICKS.md](OPENCLAW_TRICKS.md#the-gitea-notification-poller).
## CI: Gitea Actions
@@ -395,34 +371,42 @@ Everything gets a production URL with automatic TLS via Traefik.
Putting it all together, the development lifecycle looks like this:
```
1. Human labels issue with `bot` (or agent files issue)
1. Issue filed in Gitea (by human or agent)
2. Poller detects `bot` label + unassigned → assigns agent → spawns worker
2. Agent picks up the issue (via notification poller)
3. Worker agent clones repo, writes code, runs `docker build .`
3. Agent posts "starting work on #N" to Mattermost #git
4. Worker creates PR "(closes #N)", labels `needs-review`
4. Agent (or sub-agent) creates branch, writes code, pushes
5. Worker spawns reviewer agent → stops
5. Gitea webhook fires → #git shows the push
6. Reviewer agent reads diff + referenced issues → reviews
6. CI runs docker build → passes or fails
7a. Review PASS → reviewer rebases if needed → `docker build .`
→ labels `merge-ready` → assigns human → removes `bot`
7. Agent creates PR "(closes #N)"
7b. Review FAIL → reviewer labels `needs-rework`
→ spawns worker agent → back to step 3
8. Gitea webhook fires → #git shows the PR
8. Human reviews, merges
9. Agent reviews code, runs make check locally, verifies
9. Gitea webhook fires → µPaaS deploys to production
10. Agent assigns PR to human when all checks pass
10. Site/service is live
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
```
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.
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.
### Observability

View File

@@ -195,48 +195,42 @@ If your OpenClaw runs on a dedicated local machine behind NAT (like a home Mac
or Linux workstation), Gitea can't reach it directly. This is our setup —
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
@-mentions and assignments), marks them as read, and spawns one isolated agent
session per actionable item via `openclaw cron add --session isolated`.
The solution: a Python script that polls the Gitea notifications API, triages
each notification, and spawns an isolated agent session per actionable item.
Response time is ~15-30 seconds.
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.
**Evolution note:** We originally used a flag-file approach (poller writes
flag → agent checks during heartbeat → ~30 min latency). This was replaced by
the dispatcher pattern below, which is near-realtime.
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.** 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: ~1530s from notification to agent starting work.
- **The poller IS the dispatcher.** It fetches notification details, checks
whether the agent is mentioned or assigned, and spawns agents directly.
No middleman session needed.
- **One agent per actionable notification.** Each spawns via
`openclaw cron add --session isolated` with full context (API token, issue
URL, instructions) baked into the message. Parallel notifications get parallel
agents.
- **Marks notifications as read immediately.** Prevents re-processing. The
agent's job is to respond, not to manage notification state.
- **Tracks notification IDs, not counts.** Only fires on genuinely new
notifications, not re-reads of existing ones.
- **Triage before dispatch.** Not every notification is actionable. The poller
checks: is the agent @-mentioned (in issue body or latest comment)? Is the
issue/PR assigned to the agent? Is the agent's comment already the latest
(no response needed)?
- **Assignment scan as backup.** A secondary loop periodically scans watched
repos for open issues assigned to the agent that were recently updated but
have no agent response. This catches cases where notifications aren't
generated (API-created issues, self-assignment).
- **Strict scope enforcement.** Each spawned agent's prompt includes a SCOPE
constraint: "You are responsible for ONLY this issue. Do NOT touch any other
issues or PRs." This prevents rogue agents from creating unauthorized work.
- **Priority rule.** Agent prompts explicitly state that the user's instructions
in the issue override all boilerplate rules (e.g., if the user asks for a DM
response, the agent should DM).
- **Zero dependencies.** Just Python stdlib. Runs anywhere.
```python
#!/usr/bin/env python3
@@ -244,25 +238,20 @@ Response time: ~1530s from notification to agent starting work.
Gitea notification poller + dispatcher.
Two polling loops:
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.
1. Notification-based: detects new notifications (mentions, assignments)
and dispatches agents for actionable ones.
2. Assignment-based: periodically checks for open issues/PRs assigned to
the agent that have no recent response. Catches cases where
notifications aren't generated.
Required env vars:
GITEA_URL - Gitea instance URL
GITEA_TOKEN - Gitea API token
Optional env vars:
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)
POLL_DELAY - Delay between polls in seconds (default: 15)
COOLDOWN - Minimum seconds between dispatches (default: 30)
ASSIGNMENT_INTERVAL - Seconds between assignment scans (default: 120)
OPENCLAW_BIN - Path to openclaw binary
"""
@@ -278,53 +267,32 @@ 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"))
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")
ASSIGNMENT_INTERVAL = int(os.environ.get("ASSIGNMENT_INTERVAL", "120"))
OPENCLAW_BIN = os.environ.get("OPENCLAW_BIN", "/opt/homebrew/bin/openclaw")
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"
)
# Mattermost channel for status updates (customize to your setup)
GIT_CHANNEL = "channel:YOUR_GIT_CHANNEL_ID"
# Repos to watch for bot-labeled issues
# Repos to scan for assigned issues
WATCHED_REPOS = [
# "org/repo1",
# "org/repo2",
"your-org/repo1",
"your-org/repo2",
]
# Dispatch tracking (persisted to disk)
dispatched_issues: dict[str, float] = {}
# Track dispatched issues to prevent duplicates
dispatched_issues = set()
BOT_USERNAME = "your-bot-username" # e.g. "clawbot"
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 check_config():
if not GITEA_URL or not GITEA_TOKEN:
print("ERROR: GITEA_URL and GITEA_TOKEN required", file=sys.stderr)
sys.exit(1)
def gitea_api(method, path, data=None):
"""Call Gitea API, return parsed JSON or None on error."""
url = f"{GITEA_URL}/api/v1{path}"
body = json.dumps(data).encode() if data else None
headers = {"Authorization": f"token {GITEA_TOKEN}"}
@@ -336,98 +304,38 @@ def gitea_api(method, path, data=None):
raw = resp.read()
return json.loads(raw) if raw else None
except Exception as e:
print(f"WARN: {method} {path}: {e}", file=sys.stderr, flush=True)
print(f"WARN: Gitea API {method} {path}: {e}",
file=sys.stderr, flush=True)
return None
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 get_unread_notifications():
result = gitea_api("GET", "/notifications?status-types=unread")
return result if isinstance(result, list) else []
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 mark_notification_read(notif_id):
gitea_api("PATCH", f"/notifications/threads/{notif_id}")
def needs_bot_response(repo_full, issue_number):
"""True if 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_USERNAME:
return False
return True
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):
"""Check if a notification warrants spawning an agent."""
def is_actionable_notification(notif):
"""Check if a notification needs agent action.
Returns (actionable, reason, issue_number)."""
subject = notif.get("subject", {})
repo = notif.get("repository", {})
repo_full = repo.get("full_name", "")
url = subject.get("url", "")
number = url.rstrip("/").split("/")[-1] if url else ""
if not number or not number.isdigit():
@@ -437,117 +345,206 @@ def is_actionable(notif):
if not issue:
return False, "couldn't fetch issue", number
# Check for @-mentions in the latest comment
# Check assignment
assignees = [a.get("login") for a in (issue.get("assignees") or [])]
if BOT_USERNAME in assignees:
if needs_bot_response(repo_full, number):
return True, f"assigned to {BOT_USERNAME}", number
return False, "assigned but already responded", number
# Check issue body for @mention
issue_body = issue.get("body", "") or ""
issue_author = issue.get("user", {}).get("login", "")
if f"@{BOT_USERNAME}" in issue_body and issue_author != BOT_USERNAME:
if needs_bot_response(repo_full, number):
return True, f"@-mentioned in body by {issue_author}", number
# Check latest comment for @mention
comments = gitea_api(
"GET", f"/repos/{repo_full}/issues/{number}/comments"
)
if comments:
last = comments[-1]
if last.get("user", {}).get("login") == BOT_USER:
author = last.get("user", {}).get("login", "")
body = last.get("body", "") or ""
if author == BOT_USERNAME:
return False, "own comment is latest", number
if f"@{BOT_USER}" in (last.get("body") or ""):
return True, "@-mentioned in comment", number
if f"@{BOT_USERNAME}" in body:
return True, f"@-mentioned in comment by {author}", 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
return False, "not mentioned or assigned", number
def scan_bot_labeled(template):
"""Scan for issues/PRs with 'bot' label that are unassigned."""
def spawn_agent(repo_full, issue_number, title, subject_type, reason):
"""Spawn an isolated agent to handle one issue/PR."""
dispatch_key = f"{repo_full}#{issue_number}"
if dispatch_key in dispatched_issues:
return
dispatched_issues.add(dispatch_key)
repo_short = repo_full.split("/")[-1]
job_name = f"gitea-{repo_short}-{issue_number}-{int(time.time())}"
# Build agent prompt with full context
msg = (
f"Gitea notification: {reason} on {subject_type} #{issue_number} "
f"'{title}' in {repo_full}.\n\n"
f"Gitea API base: {GITEA_URL}/api/v1\n"
f"Gitea token: {GITEA_TOKEN}\n\n"
f"SCOPE (STRICT): You are responsible for ONLY {subject_type} "
f"#{issue_number} in {repo_full}. Do NOT create PRs, branches, "
f"comments, or take any action on ANY other issue or PR.\n\n"
f"PRIORITY RULE: The user's instructions in the issue/PR take "
f"priority over ALL other rules. If asked to respond in DM, do so. "
f"Later instructions override earlier ones.\n\n"
f"Instructions:\n"
f"1. Read ALL existing comments on #{issue_number} via API\n"
f"2. Follow the user's instructions\n"
f"3. If code work needed: clone to $(mktemp -d), make changes, "
f"run make check, push, comment on the issue/PR\n"
f"4. Default: post work reports as Gitea comments\n"
f"5. Don't post duplicate comments if yours is already the latest"
)
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:
print(f" → Agent spawned: {job_name}", flush=True)
else:
print(f" → Spawn failed: {result.stderr.strip()[:200]}",
flush=True)
dispatched_issues.discard(dispatch_key)
except Exception as e:
print(f" → Spawn error: {e}", file=sys.stderr, flush=True)
dispatched_issues.discard(dispatch_key)
def dispatch_notifications(notifications):
"""Triage notifications and spawn agents for actionable ones."""
for notif in notifications:
subject = notif.get("subject", {})
repo = notif.get("repository", {})
repo_full = repo.get("full_name", "")
title = subject.get("title", "")[:60]
notif_id = notif.get("id")
subject_type = subject.get("type", "").lower()
is_act, reason, issue_num = is_actionable_notification(notif)
if notif_id:
mark_notification_read(notif_id)
if is_act:
print(f" ACTIONABLE: {repo_full} #{issue_num} ({reason})",
flush=True)
spawn_agent(repo_full, issue_num, title, subject_type, reason)
else:
print(f" skip: {repo_full} #{issue_num} ({reason})", flush=True)
def scan_assigned_issues():
"""Backup scan: find assigned issues needing response."""
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 []
f"&assignee={BOT_USERNAME}&sort=updated&limit=10"
)
if not items:
continue
for item in items:
number = str(item["number"])
dispatch_key = f"{repo_full}#{number}"
last = dispatched_issues.get(dispatch_key)
if last and (time.time() - last) < REDISPATCH_COOLDOWN:
if dispatch_key in dispatched_issues:
continue
assignees = [
a.get("login", "") for a in item.get("assignees") or []
]
if BOT_USER in assignees:
if not needs_bot_response(repo_full, number):
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"
print(f" [assign-scan] {repo_full} {kind} #{number}",
flush=True)
spawn_agent(
template, repo_full, number,
item.get("title", "")[:60],
repo_full, number, item.get("title", "")[:60],
"pull" if issue_type == "pulls" else "issue",
"bot label, unassigned",
f"assigned to {BOT_USERNAME}"
)
def main():
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)})",
check_config()
print(f"Gitea poller+dispatcher (poll={POLL_DELAY}s, "
f"cooldown={COOLDOWN}s, assign_scan={ASSIGNMENT_INTERVAL}s)",
flush=True)
seen_ids = set(
n["id"] for n in
(gitea_api("GET", "/notifications?status-types=unread") or [])
)
last_dispatch = 0
last_bot_scan = 0
seen_ids = set(n["id"] for n in get_unread_notifications())
last_dispatch_time = 0
last_assign_scan = 0
print(f"Initial unread: {len(seen_ids)} (draining)", flush=True)
while True:
time.sleep(POLL_DELAY)
now = time.time()
# --- Notification polling ---
notifs = gitea_api("GET", "/notifications?status-types=unread") or []
current_ids = {n["id"] for n in notifs}
# Notification polling
notifications = get_unread_notifications()
current_ids = {n["id"] for n in notifications}
new_ids = current_ids - seen_ids
if new_ids and now - last_dispatch >= COOLDOWN:
for n in [n for n in notifs if n["id"] in new_ids]:
nid = n.get("id")
if nid:
gitea_api("PATCH", f"/notifications/threads/{nid}")
is_act, reason, num = is_actionable(n)
if is_act:
repo = n["repository"]["full_name"]
title = n["subject"]["title"][:60]
stype = n["subject"].get("type", "").lower()
spawn_agent(template, repo, num, title, stype, reason)
last_dispatch = now
if new_ids:
ts = time.strftime("%H:%M:%S")
new_notifs = [n for n in notifications if n["id"] in new_ids]
print(f"[{ts}] {len(new_ids)} new notification(s)", flush=True)
if now - last_dispatch_time >= COOLDOWN:
dispatch_notifications(new_notifs)
last_dispatch_time = now
else:
remaining = int(COOLDOWN - (now - last_dispatch_time))
print(f" → Cooldown ({remaining}s remaining)", flush=True)
seen_ids = current_ids
# --- Bot label scan (less frequent) ---
if now - last_bot_scan >= BOT_SCAN_INTERVAL:
scan_bot_labeled(template)
last_bot_scan = now
# Assignment scan (less frequent)
if now - last_assign_scan >= ASSIGNMENT_INTERVAL:
scan_assigned_issues()
last_assign_scan = now
if __name__ == "__main__":
main()
```
Run it as a background service (launchd on macOS, systemd on Linux) with the env
vars set. It's intentionally simple — no frameworks, no async, no dependencies.
Run it as a background service (launchd on macOS, systemd on Linux) with
`GITEA_URL` and `GITEA_TOKEN` set. Customize `WATCHED_REPOS`, `BOT_USERNAME`,
and `GIT_CHANNEL` for your setup. It's intentionally simple — no frameworks,
no async, no dependencies.
**Lessons learned during development:**
- `openclaw cron add --at` uses formats like `1s`, `20m` — not `+5s` or `+0s`.
- `--no-deliver` is incompatible with `--session main`. Use
`--session isolated` with `--no-deliver`.
- `--system-event` targets the main DM session. If your agent is active in a
channel session, it won't see system events. Use `--session isolated` with
`--message` instead.
- Isolated agent sessions don't have access to workspace files (TOOLS.md, etc).
Bake all credentials and instructions directly into the agent prompt.
- Agents WILL go out of scope unless the SCOPE constraint is extremely explicit
and uses strong language ("violating scope is a critical failure").
- When the user's explicit instructions in an issue conflict with boilerplate
rules in the agent prompt, the agent will follow the boilerplate unless the
prompt explicitly says "user instructions take priority."
### The Daily Diary
@@ -884,27 +881,25 @@ From REPO_POLICIES.md and our operational experience:
#### The PR Pipeline
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:
Our agent follows a strict PR lifecycle:
```markdown
## PR pipeline (every PR, no exceptions)
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.
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
- docker build . is the ONLY authoritative check (runs make check inside)
- "Passes checks" ≠ "ready for human"
- Never weaken tests/linters. Fix the code.
- Pre-existing failures are YOUR problem. Fix them as part of your PR.
```
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.
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.
#### New Repo Bootstrap
@@ -1756,12 +1751,12 @@ For complex coding tasks, spawn isolated sub-agents.
### Sub-Agent PR Quality Gate (MANDATORY)
- `docker build .` must pass. This is identical to CI and the only
authoritative check. No exceptions.
- `make check` must pass with ZERO failures. 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 — each agent spawns a separate agent for review
- Never self-review
```
---