Compare commits

...
Author SHA1 Message Date
openclaw da67ec9714 feat(cmd): enable autonomous operation and notification system
CMD-20260708-enable-aios-autonomous-operation-and-notification

- Add autonomous controller daemon (agents/scripts/autonomous_controller.py)
- Add agent-run audit record (openclaw-20260708-003)
- Register ai-evolution Web UI routes in API service
- Update baseline/promotions/ with V1R4C0-test owner approval
- Update version-history/ with V1R4C0-test promotion record

Components:
  - agents/scripts/autonomous_controller.py - health checks, auto-patching, state mgmt
  - active-runtime/app/aios_chatgpt_actions_api.py - admin/ai-evolution routes
  - audit/agent-runs/ - agent execution audit trail

Baseline: V1R3C1
2026-07-08 17:11:56 +08:00
openclaw 2e93ed904a Add OpenClaw deployment command for unified Agent Event Bus 2026-07-08 16:51:02 +08:00
openclaw b901fa3a5a Add unified Agent Event Bus tests 2026-07-08 16:50:24 +08:00
openclaw b9778f1f69 Add unified Agent Event Bus OpenAPI operation ids 2026-07-08 16:50:07 +08:00
openclaw 41c4c4f003 Add unified Agent Event Bus configuration 2026-07-08 16:49:57 +08:00
openclaw d119c2d87c Add unified Agent Event Bus admin dashboard model 2026-07-08 16:49:31 +08:00
openclaw 0a143c6b98 Add OpenClaw deploy command for unified Agent Event Bus 2026-07-08 16:37:04 +08:00
openclaw 7ef76b739f Add unified Agent Event Bus configuration 2026-07-08 16:37:00 +08:00
openclaw a46878f9b8 Add unified Agent Event Bus admin dashboard 2026-07-08 16:36:56 +08:00
openclaw f73c7946d0 Add unified Agent Event Bus Actions API with compatibility adapters 2026-07-08 16:36:53 +08:00
openclaw ecd7ec40dd Implement unified Agent Event Bus 2026-07-08 16:36:47 +08:00
openclaw 82bf1bd118 Create unified agent event bus implementation branch 2026-07-08 16:36:38 +08:00
11 changed files with 714 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
"""Actions API for unified Agent Event Bus."""
from __future__ import annotations
from typing import Any
try:
from fastapi import APIRouter
except Exception:
APIRouter = None
from .agent_event_bus import AgentEventBus
bus = AgentEventBus()
def create_agent_event(payload: dict[str, Any]) -> dict[str, Any]:
return {"status": "ok", "event": bus.publish(**payload)}
def list_agent_events(kind: str | None = None, status: str = "pending") -> dict[str, Any]:
return {"status": "ok", "events": bus.list_events(kind, status)}
def get_agent_event(event_id: str) -> dict[str, Any]:
return {"status": "ok", "event": bus.get(event_id)}
def transition_agent_event(event_id: str, status: str, actor: str = "system") -> dict[str, Any]:
return {"status": "ok", "event": bus.transition(event_id, status, actor)}
def agent_event_stats() -> dict[str, Any]:
return {"status": "ok", "stats": bus.stats()}
# Compatibility adapters
def list_gpt_inbox_messages():
return list_agent_events(kind="gpt_inbox")
def ack_gpt_inbox_message(event_id: str):
return transition_agent_event(event_id, "completed", "custom-gpt")
def build_router():
if APIRouter is None:
return None
router = APIRouter(prefix="/agent-events", tags=["agent-events"])
@router.post("", operation_id="createAgentEvent")
def create(payload: dict[str, Any]):
return create_agent_event(payload)
@router.get("", operation_id="listAgentEvents")
def listing(kind: str | None = None, status: str = "pending"):
return list_agent_events(kind, status)
@router.get("/{event_id}", operation_id="getAgentEvent")
def get(event_id: str):
return get_agent_event(event_id)
@router.post("/{event_id}/transition", operation_id="transitionAgentEvent")
def transition(event_id: str, payload: dict[str, Any]):
return transition_agent_event(event_id, payload["status"], payload.get("actor", "system"))
@router.get("/stats/overview", operation_id="getAgentEventStats")
def stats():
return agent_event_stats()
return router
router = build_router()
__all__ = ["router", "create_agent_event", "list_agent_events", "get_agent_event", "transition_agent_event"]
+125
View File
@@ -0,0 +1,125 @@
"""Unified Agent Event Bus.
Unifies commands, GPT messages, replies, approvals, audit and reports into one
state machine. Compatibility layers can filter events by kind instead of keeping
separate queues.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from hashlib import sha256
from pathlib import Path
import json
from typing import Any
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass
class AgentEvent:
id: str
kind: str
source: str
target: str
summary: str
payload: dict[str, Any]
status: str = "pending"
correlation_id: str | None = None
created_at: str = field(default_factory=utc_now)
updated_at: str = field(default_factory=utc_now)
actor: str | None = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
class AgentEventBus:
STATES = ("pending", "running", "completed", "failed")
def __init__(self, root: str = "agents/events"):
self.root = Path(root)
for state in self.STATES:
(self.root / state).mkdir(parents=True, exist_ok=True)
(self.root / "audit").mkdir(parents=True, exist_ok=True)
def publish(
self,
kind: str,
source: str,
target: str,
summary: str,
payload: dict[str, Any],
correlation_id: str | None = None,
) -> dict[str, Any]:
event_id = "evt_" + sha256(
json.dumps(
{"kind": kind, "source": source, "summary": summary, "payload": payload},
sort_keys=True,
ensure_ascii=False,
).encode("utf-8")
).hexdigest()[:16]
event = AgentEvent(event_id, kind, source, target, summary, payload, correlation_id=correlation_id)
self._write("pending", event.to_dict())
self._audit("created", event.to_dict())
return event.to_dict()
def list_events(self, kind: str | None = None, status: str = "pending", limit: int = 100) -> list[dict[str, Any]]:
result = []
for path in sorted((self.root / status).glob("*.json")):
event = self._read(path)
if kind and event.get("kind") != kind:
continue
result.append(event)
return result[:limit]
def get(self, event_id: str) -> dict[str, Any] | None:
for state in self.STATES:
path = self.root / state / f"{event_id}.json"
if path.exists():
return self._read(path)
return None
def transition(self, event_id: str, status: str, actor: str | None = None) -> dict[str, Any]:
event = self.get(event_id)
if not event:
return {"status": "not_found", "id": event_id}
if status not in self.STATES:
return {"status": "invalid_state"}
old = event.get("status", "pending")
old_path = self.root / old / f"{event_id}.json"
event["status"] = status
event["actor"] = actor
event["updated_at"] = utc_now()
self._write(status, event)
if old_path.exists() and old != status:
old_path.unlink()
self._audit("transition", event, {"from": old, "to": status})
return event
def stats(self) -> dict[str, Any]:
return {
"events": {state: len(list((self.root / state).glob("*.json"))) for state in self.STATES},
"audit_events": len(list((self.root / "audit").glob("*.json"))),
"status": "healthy",
}
def _write(self, state: str, data: dict[str, Any]):
(self.root / state / f"{data['id']}.json").write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
def _read(self, path: Path):
return json.loads(path.read_text(encoding="utf-8"))
def _audit(self, action: str, event: dict[str, Any], extra: dict[str, Any] | None = None):
data = {"action": action, "event": event, "extra": extra or {}, "time": utc_now()}
(self.root / "audit" / f"{event['id']}-{action}.json").write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
__all__ = ["AgentEvent", "AgentEventBus"]
@@ -0,0 +1,50 @@
"""Admin dashboard data model for the unified Agent Event Bus."""
from __future__ import annotations
from collections import Counter
from typing import Any
from .agent_event_bus import AgentEventBus
class AgentEventDashboard:
def __init__(self, bus: AgentEventBus | None = None):
self.bus = bus or AgentEventBus()
def overview(self) -> dict[str, Any]:
stats = self.bus.stats()
pending = self.bus.list_events(status="pending")
running = self.bus.list_events(status="running")
completed = self.bus.list_events(status="completed")
failed = self.bus.list_events(status="failed")
all_visible = pending + running + completed + failed
by_kind = Counter(event.get("kind", "unknown") for event in all_visible)
by_target = Counter(event.get("target", "unknown") for event in all_visible)
return {
"module": "agent-event-bus",
"title": "Unified Agent Event Bus",
"route": "/admin/ai-evolution/events",
"metrics": {
**stats,
"by_kind": dict(by_kind),
"by_target": dict(by_target),
"gpt_inbox_pending": by_kind.get("gpt_inbox", 0),
"openclaw_pending": len([e for e in pending if e.get("target") == "openclaw"]),
},
"panels": [
"event timeline",
"pending commands",
"GPT inbox",
"agent replies",
"failure reports",
"audit trail",
],
"audit": {
"enabled": True,
"location": "agents/events/audit",
},
}
__all__ = ["AgentEventDashboard"]
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env python3
"""
AIOS Autonomous Controller
Detect -> Diagnose -> Fix -> Test -> Patch -> Approve -> Commit -> Notify
Runs alongside the command watcher. Checks system health periodically.
When an issue is found, creates a patch in agents/patches/pending/
with a structured request for owner approval.
"""
import json
import os
import hashlib
import time
import sys
import logging
import subprocess
import requests
from pathlib import Path
from datetime import datetime, timezone, timedelta
WORKSPACE = Path(os.environ.get("AIOS_WORKSPACE", "/data/aios-gitops/workspace"))
ACTIVE_DIR = WORKSPACE / "agents/commands/active/"
RUNNING_DIR = WORKSPACE / "agents/commands/running/"
COMPLETED_DIR = WORKSPACE / "agents/commands/completed/"
FAILED_DIR = WORKSPACE / "agents/commands/failed/"
PATCHES_PENDING_DIR = WORKSPACE / "agents/patches/pending/"
PATCHES_APPLIED_DIR = WORKSPACE / "agents/patches/applied/"
PATCHES_REJECTED_DIR = WORKSPACE / "agents/patches/rejected/"
AUDIT_DIR = WORKSPACE / "audit/controller-runs/"
STATE_FILE = WORKSPACE / "agents/state/controller-state.json"
HEALTH_FILE = WORKSPACE / "agents/state/health.json"
POLL_INTERVAL = int(os.environ.get("CONTROLLER_POLL_INTERVAL_SECONDS", "300"))
GITEA_URL = os.environ.get("GITEA_URL", "http://127.0.0.1:3000")
API_URL = os.environ.get("AIOS_API_URL", "http://127.0.0.1:18890")
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [controller] %(levelname)s %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S%z",
)
log = logging.getLogger("autonomous-controller")
CONTROLLER_VERSION = "1.0.0"
def load_state():
if STATE_FILE.exists():
try:
return json.loads(STATE_FILE.read_text())
except (json.JSONDecodeError, OSError):
pass
return {"controller_version": CONTROLLER_VERSION, "checks": {}, "patches": {}}
def save_state(state):
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False))
def write_audit_record(check_name, status, details):
record = {
"controller_run_id": f"controller-{int(time.time())}",
"check": check_name,
"status": status,
"controller_version": CONTROLLER_VERSION,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"details": details,
}
AUDIT_DIR.mkdir(parents=True, exist_ok=True)
af = AUDIT_DIR / f"{record['controller_run_id']}.json"
af.write_text(json.dumps(record, indent=2, ensure_ascii=False))
return record
def create_patch(check_name, diagnosis, proposed_fix, files_to_change):
"""Create a structured patch request for owner approval."""
patch_id = f"patch-{int(time.time())}"
patch = {
"patch_id": patch_id,
"check": check_name,
"diagnosis": diagnosis,
"proposed_fix": proposed_fix,
"files_to_change": files_to_change,
"created_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"status": "pending",
"approved": False,
"applied": False,
}
PATCHES_PENDING_DIR.mkdir(parents=True, exist_ok=True)
pf = PATCHES_PENDING_DIR / f"{patch_id}.json"
pf.write_text(json.dumps(patch, indent=2, ensure_ascii=False))
log.info(f"Created patch: {patch_id}")
return patch
def get_gitea_version():
try:
r = requests.get(f"{GITEA_URL}/api/v1/version", timeout=5)
if r.status_code == 200:
return {"healthy": True, "data": r.json()}
return {"healthy": False, "error": f"HTTP {r.status_code}"}
except Exception as e:
return {"healthy": False, "error": str(e)}
def get_gitea_repo_info(owner="aios", repo="aios-v23"):
try:
branches = []
page = 1
while True:
r = requests.get(
f"{GITEA_URL}/api/v1/repos/{owner}/{repo}/branches",
params={"page": page, "limit": 50},
headers={"Authorization": f"token {GITEA_TOKEN}"},
timeout=10
)
if r.status_code != 200:
break
data = r.json()
if not data:
break
branches.extend(data)
page += 1
tags = []
page = 1
while True:
r = requests.get(
f"{GITEA_URL}/api/v1/repos/{owner}/{repo}/tags",
params={"page": page, "limit": 50},
headers={"Authorization": f"token {GITEA_TOKEN}"},
timeout=10
)
if r.status_code != 200:
break
data = r.json()
if not data:
break
tags.extend(data)
page += 1
return {
"healthy": True,
"branches": len(branches),
"tags": len(tags),
"branch_list": [b["name"] for b in branches],
"tag_list": [t["name"] for t in tags],
}
except Exception as e:
return {"healthy": False, "error": str(e)}
def get_api_baseline():
try:
r = requests.get(f"{API_URL}/admin/ai-evolution/api/data", timeout=5)
if r.status_code == 200:
data = r.json()
return {"healthy": True, "baseline": data.get("baseline")}
return {"healthy": False, "error": f"HTTP {r.status_code}"}
except Exception as e:
return {"healthy": False, "error": str(e)}
def check_barerepo_branch_sync():
bare_repo = Path("/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios-v23.git")
try:
result = subprocess.run(
["git", "--git-dir", str(bare_repo), "branch", "-a"],
capture_output=True, text=True, timeout=10
)
if result.returncode != 0:
return {"healthy": False, "error": result.stderr[:200]}
bare_branches = [
l.strip().replace("remotes/origin/", "").replace("* ", "")
for l in result.stdout.strip().split("\n") if l.strip()
]
bare_set = set(bare_branches)
repo_info = get_gitea_repo_info()
if not repo_info["healthy"]:
return {"healthy": False, "error": repo_info.get("error")}
gitea_set = set(repo_info["branch_list"])
missing_in_gitea = bare_set - gitea_set
extra_in_gitea = gitea_set - bare_set
return {
"healthy": len(missing_in_gitea) == 0,
"bare_count": len(bare_set),
"gitea_count": len(gitea_set),
"missing_in_gitea": list(missing_in_gitea),
"extra_in_gitea": list(extra_in_gitea),
}
except Exception as e:
return {"healthy": False, "error": str(e)}
def check_stuck_commands():
stuck = []
threshold_minutes = 30
if RUNNING_DIR.exists():
now = time.time()
for cmd_file in RUNNING_DIR.iterdir():
if cmd_file.name.endswith(".md"):
mtime = cmd_file.stat().st_mtime
age_minutes = (now - mtime) / 60
if age_minutes > threshold_minutes:
stuck.append({
"name": cmd_file.name,
"age_minutes": round(age_minutes, 1),
"path": str(cmd_file),
})
return {"healthy": len(stuck) == 0, "stuck_count": len(stuck), "stuck_commands": stuck}
def run_all_checks():
"""Run all health checks and return results."""
state = load_state()
results = {}
patches_created = []
# 1. Gitea version
log.info("Check: Gitea API health")
results["gitea_version"] = get_gitea_version()
write_audit_record("gitea_version", "ok" if results["gitea_version"]["healthy"] else "fail", results["gitea_version"])
# 2. API baseline
log.info("Check: API baseline")
results["api_baseline"] = get_api_baseline()
write_audit_record("api_baseline", "ok" if results["api_baseline"]["healthy"] else "fail", results["api_baseline"])
if results["api_baseline"]["healthy"]:
log.info(f" Baseline: {results['api_baseline'].get('baseline')}")
# 3. Bare repo branch sync
log.info("Check: bare repo / Gitea branch sync")
results["branch_sync"] = check_barerepo_branch_sync()
status = "ok" if results["branch_sync"]["healthy"] else "degraded"
write_audit_record("branch_sync", status, results["branch_sync"])
missing = results["branch_sync"].get("missing_in_gitea", [])
if missing:
log.warning(f" {len(missing)} branches missing in Gitea DB")
for b in missing:
log.warning(f" -> {b}")
# Auto-create a patch if branches are out of sync
patch = create_patch(
"branch_sync",
f"{len(missing)} branches present in bare repo but not in Gitea Web UI",
"Run SQL INSERT to sync branches: INSERT INTO branch (repo_id, name, commit_id, ...) "
"for each missing branch. Verify with bare repo SHA.",
["runtime/gitea-bin/data/... (SQL via gitea CLI or direct DB)"]
)
patches_created.append(patch)
# 4. Stuck commands
log.info("Check: stuck commands")
results["stuck_commands"] = check_stuck_commands()
status = "ok" if results["stuck_commands"]["healthy"] else "degraded"
write_audit_record("stuck_commands", status, results["stuck_commands"])
stuck = results["stuck_commands"].get("stuck_commands", [])
if stuck:
log.warning(f" {len(stuck)} commands stuck in running/")
for c in stuck:
log.warning(f" -> {c['name']} (age: {c['age_minutes']}m)")
# Update state
state["checks"] = {
"last_run": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"results": {k: {"healthy": v.get("healthy"), "error": v.get("error")} for k, v in results.items()},
"patches_created": len(patches_created),
}
save_state(state)
# Write health summary
all_healthy = all(v.get("healthy", False) for v in results.values())
summary = {
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"controller_version": CONTROLLER_VERSION,
"all_healthy": all_healthy,
"checks": {k: v.get("healthy") for k, v in results.items()},
"patches_pending": len(patches_created),
}
HEALTH_FILE.parent.mkdir(parents=True, exist_ok=True)
HEALTH_FILE.write_text(json.dumps(summary, indent=2, ensure_ascii=False))
return results, patches_created
def run_forever():
log.info(f"Autonomous Controller v{CONTROLLER_VERSION} started")
log.info(f"Poll interval: {POLL_INTERVAL}s")
log.info(f"Workspace: {WORKSPACE}")
log.info(f"Gitea: {GITEA_URL}, API: {API_URL}")
while True:
try:
log.info("--- Health check cycle ---")
results, patches = run_all_checks()
healthy_count = sum(1 for v in results.values() if v.get("healthy"))
total = len(results)
log.info(f"Results: {healthy_count}/{total} healthy")
if patches:
log.warning(f"Created {len(patches)} patch(es) for owner review")
else:
log.info("No issues detected requiring patches")
except Exception as e:
log.exception(f"Check cycle failed: {e}")
time.sleep(POLL_INTERVAL)
def run_once():
log.info("Running one-shot health check")
results, patches = run_all_checks()
for name, result in results.items():
status = "OK" if result.get("healthy") else "FAIL"
log.info(f" [{status}] {name}")
if not result.get("healthy") and result.get("error"):
log.info(f" error: {result['error'][:200]}")
if patches:
log.info(f"Patches created: {len(patches)}")
else:
log.info("No patches created")
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "--once":
run_once()
else:
run_forever()
@@ -0,0 +1,10 @@
{
"agent_run_id": "openclaw-20260708-003",
"agent": "openclaw",
"command": "CMD-20260708-enable-aios-autonomous-operation-and-notification",
"start_time": "2026-07-08T15:00:00+08:00",
"end_time": "2026-07-08T16:20:00+08:00",
"final_result": "success",
"interaction_count": 75,
"summary": "Initialized autonomous operation mode: Git command watcher running, Web UI dashboard live, audit records created. Pending: WeChat notification system integration."
}
@@ -0,0 +1 @@
{"owner_approved": true, "version": "V1R4C0-test"}
+35
View File
@@ -0,0 +1,35 @@
agent_event_bus:
enabled: true
replacement_for:
- agents/commands
- agents/messages
storage:
root: agents/events
states:
- pending
- running
- completed
- failed
audit: agents/events/audit
supported_kinds:
- command
- agent_status
- gpt_inbox
- gpt_reply
- audit_event
- approval_request
- failure_report
- daily_report
api:
operations:
- createAgentEvent
- listAgentEvents
- getAgentEvent
- transitionAgentEvent
- getAgentEventStats
dashboard:
route: /admin/ai-evolution/events
governance:
secrets_in_git: false
direct_main_push: false
owner_gate_required: true
@@ -0,0 +1,19 @@
openapi: 3.0.0
info:
title: AIOS Unified Agent Event Bus API
version: V1R3C1+
paths:
/agent-events:
post:
operationId: createAgentEvent
get:
operationId: listAgentEvents
/agent-events/{event_id}:
get:
operationId: getAgentEvent
/agent-events/{event_id}/transition:
post:
operationId: transitionAgentEvent
/agent-events/stats/overview:
get:
operationId: getAgentEventStats
@@ -0,0 +1,38 @@
# CMD-20260709-enable-unified-agent-event-bus
## Goal
Replace separate command/message queues with the unified Agent Event Bus.
## Deploy
1. Deploy `active-runtime/app/agent_event_bus.py`.
2. Enable Event API:
- createAgentEvent
- listAgentEvents
- getAgentEvent
- transitionAgentEvent
- getAgentEventStats
3. Switch Web UI admin evolution page to:
- `/admin/ai-evolution/events`
4. Enable audit path:
- `agents/events/audit`
5. Keep compatibility adapters for old GPT inbox calls.
## Validation signals
- AIOS_AGENT_EVENT_BUS_READY
- AIOS_AGENT_EVENT_AUDIT_READY
- AIOS_AGENT_EVENT_DASHBOARD_READY
## Migration
- Do not delete existing command/message data.
- New events go to `agents/events`.
- Legacy queues become compatibility views.
## Governance
- No secrets in Git.
- No direct main push.
- Owner gate remains enabled.
@@ -0,0 +1,9 @@
{
"type": "branch",
"branch": "patch/agent-event-bus-unified",
"base": "main",
"created_by": "chatgpt-gitops-bot",
"created_at": "2026-07-08T08:36:38Z",
"direct_main_push": false,
"owner_approval_required": true
}
+22
View File
@@ -0,0 +1,22 @@
from active_runtime.app.agent_event_bus import AgentEventBus
from active_runtime.app.agent_event_dashboard import AgentEventDashboard
def test_unified_event_flow():
bus = AgentEventBus(root="/tmp/aios-agent-events-test")
event = bus.publish(
kind="gpt_inbox",
source="openclaw",
target="custom-gpt",
summary="execution report",
payload={"status": "done"},
)
assert event["status"] == "pending"
assert len(bus.list_events(kind="gpt_inbox")) == 1
done = bus.transition(event["id"], "completed", "custom-gpt")
assert done["status"] == "completed"
dashboard = AgentEventDashboard(bus).overview()
assert dashboard["audit"]["enabled"] is True