Compare commits
3 Commits
update-pol
...
update-pol
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae6c59b28f | ||
| ccf08cfb67 | |||
|
|
0284ea63c0 |
244
AUTOMATED_DEV.md
244
AUTOMATED_DEV.md
@@ -74,103 +74,108 @@ back to issues.
|
|||||||
|
|
||||||
### PR State Machine
|
### PR State Machine
|
||||||
|
|
||||||
Once a PR exists, it enters a finite state machine tracked by Gitea labels and
|
Once a PR exists, it enters a finite state machine tracked by Gitea labels. Each
|
||||||
issue assignments. Labels represent the current state; the assignment field
|
PR has exactly one state label at a time, plus a `bot` label indicating it's the
|
||||||
represents who's responsible for the next action.
|
agent's turn to act.
|
||||||
|
|
||||||
#### States (Gitea Labels)
|
#### States (Gitea Labels)
|
||||||
|
|
||||||
| Label | Color | Meaning |
|
| Label | Color | Meaning |
|
||||||
| -------------- | ------ | ------------------------------------------------- |
|
| -------------- | ------ | --------------------------------------------- |
|
||||||
| `needs-rebase` | red | PR has merge conflicts or is behind main |
|
| `needs-review` | yellow | Code pushed, `docker build .` passes, awaiting review |
|
||||||
| `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 |
|
| `needs-rework` | purple | Code review found issues that need fixing |
|
||||||
| `merge-ready` | green | All checks pass, reviewed, rebased, conflict-free |
|
| `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
|
Worker agent (writes/fixes code)
|
||||||
│
|
→ docker build . → push → label needs-review
|
||||||
▼
|
→ unassign self → spawn reviewer agent → STOP
|
||||||
[needs-rebase] ──rebase onto main──▶ [needs-checks]
|
|
||||||
▲ │
|
Reviewer agent (reviews code it didn't write)
|
||||||
│ run make check
|
→ read diff + referenced issues → review
|
||||||
│ (main updated, │
|
→ PASS: rebase if needed → docker build . → label merge-ready
|
||||||
│ conflicts) ┌─────────────┴──────────────┐
|
→ assign human → remove bot label → STOP
|
||||||
│ │ │
|
→ FAIL: comment findings → label needs-rework
|
||||||
│ passes fails
|
→ unassign self → spawn worker agent → STOP
|
||||||
│ │ │
|
|
||||||
│ ▼ ▼
|
|
||||||
│ [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 can repeat multiple times: rebase → check → review → rework → rebase →
|
The cycle repeats (worker → reviewer → worker → reviewer → ...) until the
|
||||||
check → review → rework → ... until the PR is clean. Each iteration typically
|
reviewer approves. Each agent is a fresh session with no memory of previous
|
||||||
addresses a smaller set of issues until everything converges.
|
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
|
Just before changing labels or assignments, agents re-read all comments and
|
||||||
agent's job to drive it forward through the state machine.
|
current labels via the API. If the state changed since they started (another
|
||||||
- **PR reaches `merge-ready`** → assigned to the human. This is the ONLY time a
|
agent already acted), they report the conflict and stop. This prevents stale
|
||||||
PR should land in the human's queue.
|
agents from overwriting fresh state.
|
||||||
- **Human requests changes during review** → PR moves back to `needs-rework`,
|
|
||||||
reassigned to agent.
|
|
||||||
|
|
||||||
This means the human's PR inbox contains only PRs that are genuinely ready to
|
#### Race Detection
|
||||||
merge — no half-finished work, no failing CI, no merge conflicts. Everything
|
|
||||||
else is the agent's problem.
|
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
|
#### 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`
|
1. Worker agent creates PR, runs `docker build .`, labels `needs-review`
|
||||||
2. Agent rebases onto main → labels `needs-checks`
|
2. Worker spawns reviewer agent
|
||||||
3. Agent runs `make check` — lint fails → fixes lint, pushes → back to
|
3. Reviewer reads diff — finds a missing error check → labels `needs-rework`
|
||||||
`needs-rebase` (new commit)
|
4. Reviewer spawns worker agent
|
||||||
4. Agent rebases → `needs-checks` → runs checks → passes → `needs-review`
|
5. Worker fixes the error check, rebases, runs `docker build .`, labels
|
||||||
5. Agent does code review — finds a missing error check → `needs-rework`
|
`needs-review`
|
||||||
6. Agent fixes the error check, pushes → `needs-rebase`
|
6. Worker spawns reviewer agent
|
||||||
7. Agent rebases → `needs-checks` → passes → `needs-review`
|
7. Reviewer reads diff — looks good → rebases → `docker build .` → labels
|
||||||
8. Agent reviews — looks good → `merge-ready`
|
`merge-ready`, assigns human
|
||||||
9. Agent assigns to human
|
8. Human reviews, merges
|
||||||
10. Human reviews, merges
|
|
||||||
|
|
||||||
Steps 1-9 happen without human involvement. The human sees a clean, reviewed,
|
Steps 1-7 happen without human involvement. Each step is a separate agent
|
||||||
passing PR ready for a final look.
|
session that spawns the next one.
|
||||||
|
|
||||||
#### Automated Sweep
|
#### Safety Net
|
||||||
|
|
||||||
A periodic cron job (every 4 hours) scans all open PRs across all repos:
|
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
|
||||||
- **No label** → classify into the correct state
|
broken chains — if an agent crashes or times out without spawning the next agent,
|
||||||
- **`needs-rebase`** → spawn agent to rebase
|
the poller will eventually re-dispatch. A 30-minute cooldown prevents duplicate
|
||||||
- **`needs-checks`** → spawn agent to run checks and fix failures
|
dispatches during normal operation.
|
||||||
- **`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
|
#### Why Labels + Assignments
|
||||||
|
|
||||||
@@ -263,26 +268,45 @@ A practical setup:
|
|||||||
- **DM with agent** — Private conversation, sitreps, sensitive commands
|
- **DM with agent** — Private conversation, sitreps, sensitive commands
|
||||||
- **Project-specific channels** — For coordination with external collaborators
|
- **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
|
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
|
issue), we built a Python script that both polls and dispatches. It polls the
|
||||||
API every 2 seconds and wakes the agent via OpenClaw's `/hooks/wake` endpoint
|
Gitea notifications API every 15 seconds, triages each notification (checking
|
||||||
when new notifications arrive.
|
@-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:
|
Key design decisions:
|
||||||
|
|
||||||
- **The poller never marks notifications as read.** That's the agent's job after
|
- **The poller IS the dispatcher.** No flag files, no heartbeat dependency. The
|
||||||
processing. Prevents the poller and agent from racing.
|
poller triages notifications and spawns agents directly.
|
||||||
- **Tracks notification IDs, not counts.** Only fires on genuinely new
|
- **Marks notifications as read immediately.** Prevents re-dispatch on the next
|
||||||
notifications, not re-reads of existing ones.
|
poll cycle.
|
||||||
- **The wake message tells the agent to route output to Gitea/Mattermost, not
|
- **Assigns the agent account at dispatch time.** Before spawning the agent
|
||||||
DM.** Prevents chatty notification processing from disturbing the human.
|
session, the poller assigns the bot user to the issue via API. This prevents
|
||||||
- **Zero dependencies.** Python stdlib only (`urllib`, `json`, `time`). Runs
|
race conditions — subsequent scans skip assigned issues.
|
||||||
anywhere.
|
- **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
|
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
|
## 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:
|
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
|
10. Site/service is live
|
||||||
↓
|
|
||||||
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-10 can happen without any human involvement. The human's role is reduced
|
Steps 2-7 happen without any human involvement, driven by agent-to-agent
|
||||||
to: review the PR, approve or request changes, merge. Everything else is
|
chaining. The human's role is reduced to: label the issue, review the final PR,
|
||||||
automated.
|
merge. Everything else is automated.
|
||||||
|
|
||||||
### Observability
|
### Observability
|
||||||
|
|
||||||
|
|||||||
@@ -195,42 +195,48 @@ 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 —
|
or Linux workstation), Gitea can't reach it directly. This is our setup —
|
||||||
OpenClaw runs on a Mac Studio on a home LAN.
|
OpenClaw runs on a Mac Studio on a home LAN.
|
||||||
|
|
||||||
The solution: a Python script that polls the Gitea notifications API, triages
|
The solution: a Python script that both polls and dispatches. It polls the Gitea
|
||||||
each notification, and spawns an isolated agent session per actionable item.
|
notifications API every 15 seconds, triages each notification (checking
|
||||||
Response time is ~15-30 seconds.
|
@-mentions and assignments), marks them as read, and spawns one isolated agent
|
||||||
|
session per actionable item via `openclaw cron add --session isolated`.
|
||||||
|
|
||||||
**Evolution note:** We originally used a flag-file approach (poller writes
|
The poller also runs a secondary **label scan** every 2 minutes, checking all
|
||||||
flag → agent checks during heartbeat → ~30 min latency). This was replaced by
|
watched repos for open issues/PRs with the `bot` label that are unassigned. This
|
||||||
the dispatcher pattern below, which is near-realtime.
|
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:
|
Key design decisions:
|
||||||
|
|
||||||
- **The poller IS the dispatcher.** It fetches notification details, checks
|
- **The poller IS the dispatcher.** No flag files, no heartbeat dependency. The
|
||||||
whether the agent is mentioned or assigned, and spawns agents directly.
|
poller triages notifications and spawns agents directly.
|
||||||
No middleman session needed.
|
- **Marks notifications as read immediately.** Prevents re-dispatch on the next
|
||||||
- **One agent per actionable notification.** Each spawns via
|
poll cycle.
|
||||||
`openclaw cron add --session isolated` with full context (API token, issue
|
- **Assigns the bot user at dispatch time.** Before spawning the agent, the
|
||||||
URL, instructions) baked into the message. Parallel notifications get parallel
|
poller assigns the bot account to the issue via API. This prevents race
|
||||||
agents.
|
conditions — subsequent scans skip assigned issues. The spawned agent doesn't
|
||||||
- **Marks notifications as read immediately.** Prevents re-processing. The
|
need to claim ownership; it's already claimed.
|
||||||
agent's job is to respond, not to manage notification state.
|
- **Persistent dispatch tracking.** Dispatched issues are tracked in a JSON
|
||||||
- **Tracks notification IDs, not counts.** Only fires on genuinely new
|
file on disk (not just in memory), surviving poller restarts. Entries
|
||||||
notifications, not re-reads of existing ones.
|
auto-prune after 1 hour.
|
||||||
- **Triage before dispatch.** Not every notification is actionable. The poller
|
- **30-minute re-dispatch cooldown.** Safety net for broken agent chains. Normal
|
||||||
checks: is the agent @-mentioned (in issue body or latest comment)? Is the
|
operation uses agent-to-agent chaining (each agent spawns the next), so the
|
||||||
issue/PR assigned to the agent? Is the agent's comment already the latest
|
poller only re-dispatches if the chain breaks.
|
||||||
(no response needed)?
|
- **Concurrency cap.** The poller checks how many agents are currently running
|
||||||
- **Assignment scan as backup.** A secondary loop periodically scans watched
|
(`openclaw cron list`) and defers dispatch if the cap is reached.
|
||||||
repos for open issues assigned to the agent that were recently updated but
|
- **Stale agent reaper.** Each scan cycle, kills agent sessions running longer
|
||||||
have no agent response. This catches cases where notifications aren't
|
than 10 minutes. The `--timeout-seconds` flag isn't always enforced by
|
||||||
generated (API-created issues, self-assignment).
|
OpenClaw, so the poller handles cleanup itself.
|
||||||
- **Strict scope enforcement.** Each spawned agent's prompt includes a SCOPE
|
- **`merge-ready` skip.** The label scan skips issues already labeled
|
||||||
constraint: "You are responsible for ONLY this issue. Do NOT touch any other
|
`merge-ready` — those are in the human's court.
|
||||||
issues or PRs." This prevents rogue agents from creating unauthorized work.
|
- **Template-based prompts.** The poller reads two workspace files (a dispatch
|
||||||
- **Priority rule.** Agent prompts explicitly state that the user's instructions
|
header with `{{variable}}` placeholders, and a workflow rules document),
|
||||||
in the issue override all boilerplate rules (e.g., if the user asks for a DM
|
concatenates them, substitutes variables, and passes the result as the
|
||||||
response, the agent should DM).
|
agent's `--message`. This keeps all instructions in version-controlled
|
||||||
- **Zero dependencies.** Just Python stdlib. Runs anywhere.
|
workspace files with a single source of truth.
|
||||||
|
- **Zero dependencies.** Python stdlib only. Runs anywhere.
|
||||||
|
|
||||||
|
Response time: ~15–30s from notification to agent starting work.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
@@ -238,20 +244,25 @@ Key design decisions:
|
|||||||
Gitea notification poller + dispatcher.
|
Gitea notification poller + dispatcher.
|
||||||
|
|
||||||
Two polling loops:
|
Two polling loops:
|
||||||
1. Notification-based: detects new notifications (mentions, assignments)
|
1. Notification-based: detects new @-mentions and assignments, dispatches
|
||||||
and dispatches agents for actionable ones.
|
agents for actionable notifications.
|
||||||
2. Assignment-based: periodically checks for open issues/PRs assigned to
|
2. Label-based: periodically scans for issues/PRs with the 'bot' label
|
||||||
the agent that have no recent response. Catches cases where
|
that are unassigned (available for work). Catches broken agent chains
|
||||||
notifications aren't generated.
|
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:
|
Required env vars:
|
||||||
GITEA_URL - Gitea instance URL
|
GITEA_URL - Gitea instance URL
|
||||||
GITEA_TOKEN - Gitea API token
|
GITEA_TOKEN - Gitea API token
|
||||||
|
|
||||||
Optional env vars:
|
Optional env vars:
|
||||||
POLL_DELAY - Delay between polls in seconds (default: 15)
|
POLL_DELAY - Seconds between notification polls (default: 15)
|
||||||
COOLDOWN - Minimum seconds between dispatches (default: 30)
|
COOLDOWN - Seconds between dispatch batches (default: 30)
|
||||||
ASSIGNMENT_INTERVAL - Seconds between assignment scans (default: 120)
|
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
|
OPENCLAW_BIN - Path to openclaw binary
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -267,32 +278,53 @@ GITEA_URL = os.environ.get("GITEA_URL", "").rstrip("/")
|
|||||||
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
||||||
POLL_DELAY = int(os.environ.get("POLL_DELAY", "15"))
|
POLL_DELAY = int(os.environ.get("POLL_DELAY", "15"))
|
||||||
COOLDOWN = int(os.environ.get("COOLDOWN", "30"))
|
COOLDOWN = int(os.environ.get("COOLDOWN", "30"))
|
||||||
ASSIGNMENT_INTERVAL = int(os.environ.get("ASSIGNMENT_INTERVAL", "120"))
|
BOT_SCAN_INTERVAL = int(os.environ.get("BOT_SCAN_INTERVAL", "120"))
|
||||||
OPENCLAW_BIN = os.environ.get("OPENCLAW_BIN", "/opt/homebrew/bin/openclaw")
|
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")
|
||||||
|
|
||||||
# Mattermost channel for status updates (customize to your setup)
|
WORKSPACE = os.path.expanduser("~/.openclaw/workspace")
|
||||||
GIT_CHANNEL = "channel:YOUR_GIT_CHANNEL_ID"
|
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 scan for assigned issues
|
# Repos to watch for bot-labeled issues
|
||||||
WATCHED_REPOS = [
|
WATCHED_REPOS = [
|
||||||
"your-org/repo1",
|
# "org/repo1",
|
||||||
"your-org/repo2",
|
# "org/repo2",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Track dispatched issues to prevent duplicates
|
# Dispatch tracking (persisted to disk)
|
||||||
dispatched_issues = set()
|
dispatched_issues: dict[str, float] = {}
|
||||||
|
|
||||||
BOT_USERNAME = "your-bot-username" # e.g. "clawbot"
|
|
||||||
|
|
||||||
|
|
||||||
def check_config():
|
def _load_dispatch_state() -> dict[str, float]:
|
||||||
if not GITEA_URL or not GITEA_TOKEN:
|
try:
|
||||||
print("ERROR: GITEA_URL and GITEA_TOKEN required", file=sys.stderr)
|
with open(DISPATCH_STATE_PATH) as f:
|
||||||
sys.exit(1)
|
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):
|
def gitea_api(method, path, data=None):
|
||||||
"""Call Gitea API, return parsed JSON or None on error."""
|
|
||||||
url = f"{GITEA_URL}/api/v1{path}"
|
url = f"{GITEA_URL}/api/v1{path}"
|
||||||
body = json.dumps(data).encode() if data else None
|
body = json.dumps(data).encode() if data else None
|
||||||
headers = {"Authorization": f"token {GITEA_TOKEN}"}
|
headers = {"Authorization": f"token {GITEA_TOKEN}"}
|
||||||
@@ -304,38 +336,98 @@ def gitea_api(method, path, data=None):
|
|||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
return json.loads(raw) if raw else None
|
return json.loads(raw) if raw else None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"WARN: Gitea API {method} {path}: {e}",
|
print(f"WARN: {method} {path}: {e}", file=sys.stderr, flush=True)
|
||||||
file=sys.stderr, flush=True)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_unread_notifications():
|
def load_template() -> str:
|
||||||
result = gitea_api("GET", "/notifications?status-types=unread")
|
"""Load dispatch header + workflow doc, concatenated."""
|
||||||
return result if isinstance(result, list) else []
|
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 mark_notification_read(notif_id):
|
def render_template(template, repo_full, issue_number, title,
|
||||||
gitea_api("PATCH", f"/notifications/threads/{notif_id}")
|
subject_type, reason):
|
||||||
|
return (
|
||||||
|
template
|
||||||
def needs_bot_response(repo_full, issue_number):
|
.replace("{{repo_full}}", repo_full)
|
||||||
"""True if bot is NOT the author of the most recent comment."""
|
.replace("{{issue_number}}", str(issue_number))
|
||||||
comments = gitea_api(
|
.replace("{{title}}", title)
|
||||||
"GET", f"/repos/{repo_full}/issues/{issue_number}/comments"
|
.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)
|
||||||
)
|
)
|
||||||
if comments and len(comments) > 0:
|
|
||||||
if comments[-1].get("user", {}).get("login") == BOT_USERNAME:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def is_actionable_notification(notif):
|
def count_running_agents() -> int:
|
||||||
"""Check if a notification needs agent action.
|
try:
|
||||||
Returns (actionable, reason, issue_number)."""
|
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."""
|
||||||
subject = notif.get("subject", {})
|
subject = notif.get("subject", {})
|
||||||
repo = notif.get("repository", {})
|
repo = notif.get("repository", {})
|
||||||
repo_full = repo.get("full_name", "")
|
repo_full = repo.get("full_name", "")
|
||||||
|
|
||||||
url = subject.get("url", "")
|
url = subject.get("url", "")
|
||||||
number = url.rstrip("/").split("/")[-1] if url else ""
|
number = url.rstrip("/").split("/")[-1] if url else ""
|
||||||
if not number or not number.isdigit():
|
if not number or not number.isdigit():
|
||||||
@@ -345,206 +437,117 @@ def is_actionable_notification(notif):
|
|||||||
if not issue:
|
if not issue:
|
||||||
return False, "couldn't fetch issue", number
|
return False, "couldn't fetch issue", number
|
||||||
|
|
||||||
# Check assignment
|
# Check for @-mentions in the latest comment
|
||||||
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(
|
comments = gitea_api(
|
||||||
"GET", f"/repos/{repo_full}/issues/{number}/comments"
|
"GET", f"/repos/{repo_full}/issues/{number}/comments"
|
||||||
)
|
)
|
||||||
if comments:
|
if comments:
|
||||||
last = comments[-1]
|
last = comments[-1]
|
||||||
author = last.get("user", {}).get("login", "")
|
if last.get("user", {}).get("login") == BOT_USER:
|
||||||
body = last.get("body", "") or ""
|
|
||||||
if author == BOT_USERNAME:
|
|
||||||
return False, "own comment is latest", number
|
return False, "own comment is latest", number
|
||||||
if f"@{BOT_USERNAME}" in body:
|
if f"@{BOT_USER}" in (last.get("body") or ""):
|
||||||
return True, f"@-mentioned in comment by {author}", 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):
|
def scan_bot_labeled(template):
|
||||||
"""Spawn an isolated agent to handle one issue/PR."""
|
"""Scan for issues/PRs with 'bot' label that are unassigned."""
|
||||||
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 repo_full in WATCHED_REPOS:
|
||||||
for issue_type in ["issues", "pulls"]:
|
for issue_type in ["issues", "pulls"]:
|
||||||
items = gitea_api(
|
items = gitea_api(
|
||||||
"GET",
|
"GET",
|
||||||
f"/repos/{repo_full}/issues?state=open&type={issue_type}"
|
f"/repos/{repo_full}/issues?state=open&type={issue_type}"
|
||||||
f"&assignee={BOT_USERNAME}&sort=updated&limit=10"
|
f"&labels=bot&sort=updated&limit=10",
|
||||||
)
|
) or []
|
||||||
if not items:
|
|
||||||
continue
|
|
||||||
for item in items:
|
for item in items:
|
||||||
number = str(item["number"])
|
number = str(item["number"])
|
||||||
dispatch_key = f"{repo_full}#{number}"
|
dispatch_key = f"{repo_full}#{number}"
|
||||||
if dispatch_key in dispatched_issues:
|
|
||||||
|
last = dispatched_issues.get(dispatch_key)
|
||||||
|
if last and (time.time() - last) < REDISPATCH_COOLDOWN:
|
||||||
continue
|
continue
|
||||||
if not needs_bot_response(repo_full, number):
|
|
||||||
|
assignees = [
|
||||||
|
a.get("login", "") for a in item.get("assignees") or []
|
||||||
|
]
|
||||||
|
if BOT_USER in assignees:
|
||||||
continue
|
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"
|
kind = "PR" if issue_type == "pulls" else "issue"
|
||||||
print(f" [assign-scan] {repo_full} {kind} #{number}",
|
|
||||||
flush=True)
|
|
||||||
spawn_agent(
|
spawn_agent(
|
||||||
repo_full, number, item.get("title", "")[:60],
|
template, repo_full, number,
|
||||||
|
item.get("title", "")[:60],
|
||||||
"pull" if issue_type == "pulls" else "issue",
|
"pull" if issue_type == "pulls" else "issue",
|
||||||
f"assigned to {BOT_USERNAME}"
|
"bot label, unassigned",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
check_config()
|
global dispatched_issues
|
||||||
print(f"Gitea poller+dispatcher (poll={POLL_DELAY}s, "
|
dispatched_issues = _load_dispatch_state()
|
||||||
f"cooldown={COOLDOWN}s, assign_scan={ASSIGNMENT_INTERVAL}s)",
|
|
||||||
|
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)
|
flush=True)
|
||||||
|
|
||||||
seen_ids = set(n["id"] for n in get_unread_notifications())
|
seen_ids = set(
|
||||||
last_dispatch_time = 0
|
n["id"] for n in
|
||||||
last_assign_scan = 0
|
(gitea_api("GET", "/notifications?status-types=unread") or [])
|
||||||
print(f"Initial unread: {len(seen_ids)} (draining)", flush=True)
|
)
|
||||||
|
last_dispatch = 0
|
||||||
|
last_bot_scan = 0
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
time.sleep(POLL_DELAY)
|
time.sleep(POLL_DELAY)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
# Notification polling
|
# --- Notification polling ---
|
||||||
notifications = get_unread_notifications()
|
notifs = gitea_api("GET", "/notifications?status-types=unread") or []
|
||||||
current_ids = {n["id"] for n in notifications}
|
current_ids = {n["id"] for n in notifs}
|
||||||
new_ids = current_ids - seen_ids
|
new_ids = current_ids - seen_ids
|
||||||
|
if new_ids and now - last_dispatch >= COOLDOWN:
|
||||||
if new_ids:
|
for n in [n for n in notifs if n["id"] in new_ids]:
|
||||||
ts = time.strftime("%H:%M:%S")
|
nid = n.get("id")
|
||||||
new_notifs = [n for n in notifications if n["id"] in new_ids]
|
if nid:
|
||||||
print(f"[{ts}] {len(new_ids)} new notification(s)", flush=True)
|
gitea_api("PATCH", f"/notifications/threads/{nid}")
|
||||||
if now - last_dispatch_time >= COOLDOWN:
|
is_act, reason, num = is_actionable(n)
|
||||||
dispatch_notifications(new_notifs)
|
if is_act:
|
||||||
last_dispatch_time = now
|
repo = n["repository"]["full_name"]
|
||||||
else:
|
title = n["subject"]["title"][:60]
|
||||||
remaining = int(COOLDOWN - (now - last_dispatch_time))
|
stype = n["subject"].get("type", "").lower()
|
||||||
print(f" → Cooldown ({remaining}s remaining)", flush=True)
|
spawn_agent(template, repo, num, title, stype, reason)
|
||||||
|
last_dispatch = now
|
||||||
seen_ids = current_ids
|
seen_ids = current_ids
|
||||||
|
|
||||||
# Assignment scan (less frequent)
|
# --- Bot label scan (less frequent) ---
|
||||||
if now - last_assign_scan >= ASSIGNMENT_INTERVAL:
|
if now - last_bot_scan >= BOT_SCAN_INTERVAL:
|
||||||
scan_assigned_issues()
|
scan_bot_labeled(template)
|
||||||
last_assign_scan = now
|
last_bot_scan = now
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
```
|
```
|
||||||
|
|
||||||
Run it as a background service (launchd on macOS, systemd on Linux) with
|
Run it as a background service (launchd on macOS, systemd on Linux) with the env
|
||||||
`GITEA_URL` and `GITEA_TOKEN` set. Customize `WATCHED_REPOS`, `BOT_USERNAME`,
|
vars set. It's intentionally simple — no frameworks, no async, no dependencies.
|
||||||
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
|
### The Daily Diary
|
||||||
|
|
||||||
@@ -881,25 +884,27 @@ From REPO_POLICIES.md and our operational experience:
|
|||||||
|
|
||||||
#### The PR Pipeline
|
#### 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
|
```markdown
|
||||||
## PR pipeline (every PR, no exceptions)
|
## PR pipeline (every PR, no exceptions)
|
||||||
|
|
||||||
1. **Review/rework loop**: code review → rework → re-review → repeat until clean
|
Worker agent → docker build . → push → label needs-review → spawn reviewer
|
||||||
2. **Check/rework loop**: `make check` + `docker build .` → rework → re-check →
|
Reviewer agent → review diff → PASS: docker build . → label merge-ready
|
||||||
repeat until clean
|
→ FAIL: label needs-rework → spawn worker
|
||||||
3. Only after BOTH loops pass with zero issues: assign to human
|
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.
|
- Never weaken tests/linters. Fix the code.
|
||||||
- Pre-existing failures are YOUR problem. Fix them as part of your PR.
|
- 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
|
The agent chain doesn't just create a PR and hand it off — it drives the PR
|
||||||
review, rework, and verification until it's genuinely ready. A PR assigned to
|
through review, rework, and verification until it's genuinely ready. A PR
|
||||||
the human means: all checks pass, code reviewed, review feedback addressed,
|
assigned to the human means: build passes, code reviewed by a separate agent,
|
||||||
rebased against main, no conflicts. Anything less is the agent's open task.
|
review feedback addressed, rebased. Anything less is still in the agent chain.
|
||||||
|
|
||||||
#### New Repo Bootstrap
|
#### New Repo Bootstrap
|
||||||
|
|
||||||
@@ -1751,12 +1756,12 @@ For complex coding tasks, spawn isolated sub-agents.
|
|||||||
|
|
||||||
### Sub-Agent PR Quality Gate (MANDATORY)
|
### 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.
|
- Pre-existing failures are YOUR problem. Fix them as part of your PR.
|
||||||
- NEVER modify linter config to make checks pass. Fix the code.
|
- NEVER modify linter config to make checks pass. Fix the code.
|
||||||
- Every PR must include full `make check` output
|
|
||||||
- Rebase before and after committing
|
- Rebase before and after committing
|
||||||
- Never self-review
|
- Never self-review — each agent spawns a separate agent for review
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user