Some checks failed
Smoke Test / smoke (pull_request) Failing after 13s
Implement tooling for the shared Emacs daemon control plane on Bezalel. Agents can now poll dispatch.org for tasks, claim work, and report results programmatically. Files: - scripts/emacs-fleet-bridge.py — Python client with 6 commands: poll (find PENDING tasks), claim (PENDING→IN_PROGRESS), done (mark complete), append (status messages), status (health check), eval (arbitrary Elisp). SSH-based communication with Bezalel Emacs daemon. - scripts/emacs-fleet-poll.sh — Shell poll script for crontab integration. Shows connectivity, task counts, my pending/active tasks, recent activity. - skills/autonomous-ai-agents/emacs-control-plane/SKILL.md — Full skill docs covering infrastructure, API, agent loop integration, state machine, and pitfalls. Infrastructure: - Host: Bezalel (159.203.146.185) - Socket: /root/.emacs.d/server/bezalel - Dispatch: /srv/fleet/workspace/dispatch.org - Configurable via BEZALEL_HOST, BEZALEL_SSH_KEY, EMACS_SOCKET env vars Closes #590
276 lines
10 KiB
Python
Executable File
276 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Emacs Fleet Bridge — Sovereign Control Plane Client
|
|
|
|
Interacts with the shared Emacs daemon on Bezalel to:
|
|
- Append messages to dispatch.org
|
|
- Poll for TODO tasks assigned to this agent
|
|
- Claim tasks (PENDING → IN_PROGRESS)
|
|
- Report results back to dispatch.org
|
|
- Query shared state
|
|
|
|
Usage:
|
|
python3 emacs-fleet-bridge.py poll --agent timmy
|
|
python3 emacs-fleet-bridge.py append "Deployed PR #123 to staging"
|
|
python3 emacs-fleet-bridge.py claim --task-id TASK-001
|
|
python3 emacs-fleet-bridge.py done --task-id TASK-001 --result "Merged"
|
|
python3 emacs-fleet-bridge.py status
|
|
python3 emacs-fleet-bridge.py eval "(org-element-parse-buffer)"
|
|
|
|
Requires SSH access to Bezalel. Set BEZALEL_HOST and BEZALEL_SSH_KEY env vars
|
|
or use defaults (root@159.203.146.185).
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
# ── Config ──────────────────────────────────────────────
|
|
BEZALEL_HOST = os.environ.get("BEZALEL_HOST", "159.203.146.185")
|
|
BEZALEL_USER = os.environ.get("BEZALEL_USER", "root")
|
|
BEZALEL_SSH_KEY = os.environ.get("BEZALEL_SSH_KEY", "")
|
|
SOCKET_PATH = os.environ.get("EMACS_SOCKET", "/root/.emacs.d/server/bezalel")
|
|
DISPATCH_FILE = os.environ.get("DISPATCH_FILE", "/srv/fleet/workspace/dispatch.org")
|
|
|
|
SSH_TIMEOUT = int(os.environ.get("BEZALEL_SSH_TIMEOUT", "15"))
|
|
|
|
|
|
# ── SSH Helpers ─────────────────────────────────────────
|
|
|
|
def _ssh_cmd() -> list:
|
|
"""Build base SSH command."""
|
|
cmd = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", f"ConnectTimeout={SSH_TIMEOUT}"]
|
|
if BEZALEL_SSH_KEY:
|
|
cmd.extend(["-i", BEZALEL_SSH_KEY])
|
|
cmd.append(f"{BEZALEL_USER}@{BEZALEL_HOST}")
|
|
return cmd
|
|
|
|
|
|
def emacs_eval(expr: str) -> str:
|
|
"""Evaluate an Emacs Lisp expression on Bezalel via emacsclient."""
|
|
ssh = _ssh_cmd()
|
|
elisp = expr.replace('"', '\\"')
|
|
ssh.append(f'emacsclient -s {SOCKET_PATH} -e "{elisp}"')
|
|
try:
|
|
result = subprocess.run(ssh, capture_output=True, text=True, timeout=SSH_TIMEOUT + 5)
|
|
if result.returncode != 0:
|
|
return f"ERROR: {result.stderr.strip()}"
|
|
# emacsclient wraps string results in quotes; strip them
|
|
output = result.stdout.strip()
|
|
if output.startswith('"') and output.endswith('"'):
|
|
output = output[1:-1]
|
|
return output
|
|
except subprocess.TimeoutExpired:
|
|
return "ERROR: SSH timeout"
|
|
except Exception as e:
|
|
return f"ERROR: {e}"
|
|
|
|
|
|
def ssh_run(remote_cmd: str) -> tuple:
|
|
"""Run a shell command on Bezalel. Returns (stdout, stderr, exit_code)."""
|
|
ssh = _ssh_cmd()
|
|
ssh.append(remote_cmd)
|
|
try:
|
|
result = subprocess.run(ssh, capture_output=True, text=True, timeout=SSH_TIMEOUT + 5)
|
|
return result.stdout.strip(), result.stderr.strip(), result.returncode
|
|
except subprocess.TimeoutExpired:
|
|
return "", "SSH timeout", 1
|
|
except Exception as e:
|
|
return "", str(e), 1
|
|
|
|
|
|
# ── Org Mode Operations ────────────────────────────────
|
|
|
|
def append_message(message: str, agent: str = "timmy") -> str:
|
|
"""Append a message entry to dispatch.org."""
|
|
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
entry = f"\n** [DONE] [{ts}] {agent}: {message}\n"
|
|
# Use the fleet-append wrapper if available, otherwise direct elisp
|
|
escaped = entry.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
|
elisp = f'(with-current-buffer (find-file-noselect "{DISPATCH_FILE}") (goto-char (point-max)) (insert "{escaped}") (save-buffer))'
|
|
result = emacs_eval(elisp)
|
|
return f"Appended: {message}" if "ERROR" not in result else result
|
|
|
|
|
|
def poll_tasks(agent: str = "timmy", limit: int = 10) -> list:
|
|
"""Poll dispatch.org for PENDING tasks assigned to this agent."""
|
|
# Parse org buffer looking for TODO items with agent assignment
|
|
elisp = f"""
|
|
(with-current-buffer (find-file-noselect "{DISPATCH_FILE}")
|
|
(org-element-map (org-element-parse-buffer) 'headline
|
|
(lambda (h)
|
|
(when (and (equal (org-element-property :todo-keyword h) "PENDING")
|
|
(let ((tags (org-element-property :tags h)))
|
|
(or (member "{agent}" tags)
|
|
(member "{agent.upper()}" tags))))
|
|
(list (org-element-property :raw-value h)
|
|
(or (org-element-property :ID h) "")
|
|
(org-element-property :begin h)))))
|
|
nil nil 'headline))
|
|
"""
|
|
result = emacs_eval(elisp)
|
|
if "ERROR" in result:
|
|
return [{"error": result}]
|
|
|
|
# Parse the Emacs Lisp list output into Python
|
|
try:
|
|
# emacsclient returns elisp syntax like: ((task1 id1 pos1) (task2 id2 pos2))
|
|
# We use a simpler approach: extract via a wrapper script
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
# Fallback: use grep on the file for PENDING items
|
|
stdout, stderr, rc = ssh_run(
|
|
f'grep -n "PENDING.*:{agent}:" {DISPATCH_FILE} 2>/dev/null | head -{limit}'
|
|
)
|
|
tasks = []
|
|
for line in stdout.splitlines():
|
|
parts = line.split(":", 2)
|
|
if len(parts) >= 2:
|
|
tasks.append({
|
|
"line": int(parts[0]) if parts[0].isdigit() else 0,
|
|
"content": parts[-1].strip(),
|
|
})
|
|
return tasks
|
|
|
|
|
|
def claim_task(task_id: str, agent: str = "timmy") -> str:
|
|
"""Claim a task: change PENDING → IN_PROGRESS."""
|
|
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
elisp = f"""
|
|
(with-current-buffer (find-file-noselect "{DISPATCH_FILE}")
|
|
(goto-char (point-min))
|
|
(when (re-search-forward "PENDING.*{task_id}" nil t)
|
|
(beginning-of-line)
|
|
(org-todo "IN_PROGRESS")
|
|
(end-of-line)
|
|
(insert " [Claimed by {agent} at {ts}]")
|
|
(save-buffer)
|
|
"claimed"))
|
|
"""
|
|
result = emacs_eval(elisp)
|
|
return f"Claimed task {task_id}" if "ERROR" not in result else result
|
|
|
|
|
|
def done_task(task_id: str, result_text: str = "", agent: str = "timmy") -> str:
|
|
"""Mark a task as DONE with optional result."""
|
|
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
suffix = f" [{agent}: {result_text}]" if result_text else ""
|
|
elisp = f"""
|
|
(with-current-buffer (find-file-noselect "{DISPATCH_FILE}")
|
|
(goto-char (point-min))
|
|
(when (re-search-forward "IN_PROGRESS.*{task_id}" nil t)
|
|
(beginning-of-line)
|
|
(org-todo "DONE")
|
|
(end-of-line)
|
|
(insert " [Completed by {agent} at {ts}]{suffix}")
|
|
(save-buffer)
|
|
"done"))
|
|
"""
|
|
result = emacs_eval(elisp)
|
|
return f"Done: {task_id} — {result_text}" if "ERROR" not in result else result
|
|
|
|
|
|
def status() -> dict:
|
|
"""Get control plane status."""
|
|
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
|
|
# Check connectivity
|
|
stdout, stderr, rc = ssh_run(f'emacsclient -s {SOCKET_PATH} -e "(emacs-version)" 2>&1')
|
|
connected = rc == 0 and "ERROR" not in stdout
|
|
|
|
# Count tasks by state
|
|
counts = {}
|
|
for state in ["PENDING", "IN_PROGRESS", "DONE"]:
|
|
stdout, _, _ = ssh_run(f'grep -c "{state}" {DISPATCH_FILE} 2>/dev/null || echo 0')
|
|
counts[state.lower()] = int(stdout.strip()) if stdout.strip().isdigit() else 0
|
|
|
|
# Check dispatch.org size
|
|
stdout, _, _ = ssh_run(f'wc -l {DISPATCH_FILE} 2>/dev/null || echo 0')
|
|
lines = int(stdout.split()[0]) if stdout.split()[0].isdigit() else 0
|
|
|
|
return {
|
|
"timestamp": ts,
|
|
"host": f"{BEZALEL_USER}@{BEZALEL_HOST}",
|
|
"socket": SOCKET_PATH,
|
|
"connected": connected,
|
|
"dispatch_lines": lines,
|
|
"tasks": counts,
|
|
}
|
|
|
|
|
|
# ── CLI ─────────────────────────────────────────────────
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Emacs Fleet Bridge — Sovereign Control Plane")
|
|
parser.add_argument("--agent", default="timmy", help="Agent name (default: timmy)")
|
|
sub = parser.add_subparsers(dest="command")
|
|
|
|
# poll
|
|
poll_p = sub.add_parser("poll", help="Poll for PENDING tasks")
|
|
poll_p.add_argument("--limit", type=int, default=10)
|
|
|
|
# append
|
|
append_p = sub.add_parser("append", help="Append message to dispatch.org")
|
|
append_p.add_argument("message", help="Message to append")
|
|
|
|
# claim
|
|
claim_p = sub.add_parser("claim", help="Claim a task (PENDING → IN_PROGRESS)")
|
|
claim_p.add_argument("task_id", help="Task ID to claim")
|
|
|
|
# done
|
|
done_p = sub.add_parser("done", help="Mark task as DONE")
|
|
done_p.add_argument("task_id", help="Task ID to complete")
|
|
done_p.add_argument("--result", default="", help="Result description")
|
|
|
|
# status
|
|
sub.add_parser("status", help="Show control plane status")
|
|
|
|
# eval
|
|
eval_p = sub.add_parser("eval", help="Evaluate Emacs Lisp expression")
|
|
eval_p.add_argument("expression", help="Elisp expression")
|
|
|
|
args = parser.parse_args()
|
|
agent = args.agent
|
|
|
|
if args.command == "poll":
|
|
tasks = poll_tasks(agent, args.limit)
|
|
if tasks:
|
|
for t in tasks:
|
|
if "error" in t:
|
|
print(f"ERROR: {t['error']}", file=sys.stderr)
|
|
else:
|
|
print(f" [{t.get('line', '?')}] {t.get('content', '?')}")
|
|
else:
|
|
print(f"No PENDING tasks for {agent}")
|
|
|
|
elif args.command == "append":
|
|
print(append_message(args.message, agent))
|
|
|
|
elif args.command == "claim":
|
|
print(claim_task(args.task_id, agent))
|
|
|
|
elif args.command == "done":
|
|
print(done_task(args.task_id, args.result, agent))
|
|
|
|
elif args.command == "status":
|
|
s = status()
|
|
print(json.dumps(s, indent=2))
|
|
if not s["connected"]:
|
|
print("\nWARNING: Cannot connect to Emacs daemon on Bezalel", file=sys.stderr)
|
|
|
|
elif args.command == "eval":
|
|
print(emacs_eval(args.expression))
|
|
|
|
else:
|
|
parser.print_help()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|