Adds missing standard artifacts: - README.md (if missing) - AGENTS.md (AI agent contract) - PLAN.md (current sprint) - STATUS.md (where we are) - DEVELOPMENT.md (dev workflow) - DEPLOYMENT.md (deploy procedure) - TESTING.md (test strategy) - DECISIONS.md (ADR index + templates) - .github/CODEOWNERS - .github/workflows/ci.yml Preserves all existing artifacts. Refs: RugMunchMedia/fleet-template
301 lines
11 KiB
Python
301 lines
11 KiB
Python
"""Agent Scheduler — Recurring wallet tasks.
|
|
|
|
Runs in the background alongside the main app. Handles:
|
|
- Dollar-cost averaging: generate wallet + schedule funding
|
|
- Periodic balance checks with anomaly alerts
|
|
- Scheduled rotation (rotate wallet every N days)
|
|
- Monitoring triggers (large tx alerts)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
logger = logging.getLogger("wp.agent.scheduler")
|
|
|
|
TASKS_FILE = Path(os.getenv("WP_DATA_DIR", "/data")) / "agent_tasks.json"
|
|
|
|
|
|
@dataclass
|
|
class ScheduledTask:
|
|
id: str
|
|
type: str # dca, sweep, monitor, rotate
|
|
chain: str
|
|
params: dict[str, Any] = field(default_factory=dict)
|
|
interval_seconds: int = 604800 # default: weekly
|
|
last_run: float = 0.0
|
|
next_run: float = 0.0
|
|
created_at: float = 0.0
|
|
enabled: bool = True
|
|
label: str = ""
|
|
|
|
|
|
class AgentScheduler:
|
|
"""Lightweight background scheduler for agent tasks.
|
|
|
|
Uses a background thread that checks for due tasks every 60 seconds.
|
|
Tasks are persisted to JSON for durability across restarts.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._tasks: dict[str, ScheduledTask] = {}
|
|
self._lock = threading.Lock()
|
|
self._running = False
|
|
self._thread: Optional[threading.Thread] = None
|
|
self._load()
|
|
|
|
def _path(self) -> Path:
|
|
p = Path(os.getenv("WP_DATA_DIR", "/data"))
|
|
p.mkdir(parents=True, exist_ok=True)
|
|
return p / "agent_tasks.json"
|
|
|
|
def _load(self):
|
|
path = self._path()
|
|
if path.exists():
|
|
try:
|
|
data = json.loads(path.read_text())
|
|
for k, v in data.items():
|
|
self._tasks[k] = ScheduledTask(**v)
|
|
logger.info(f"Loaded {len(self._tasks)} scheduled tasks")
|
|
except Exception as e:
|
|
logger.error(f"Failed to load tasks: {e}")
|
|
|
|
def _save(self):
|
|
path = self._path()
|
|
try:
|
|
path.write_text(json.dumps({k: v.__dict__ for k, v in self._tasks.items()}, indent=2, default=str))
|
|
except Exception as e:
|
|
logger.error(f"Failed to save tasks: {e}")
|
|
|
|
def add_task(self, task: ScheduledTask) -> str:
|
|
with self._lock:
|
|
self._tasks[task.id] = task
|
|
self._save()
|
|
logger.info(f"Scheduled task added: {task.id} ({task.type}/{task.chain})")
|
|
return task.id
|
|
|
|
def remove_task(self, task_id: str) -> bool:
|
|
with self._lock:
|
|
if task_id in self._tasks:
|
|
del self._tasks[task_id]
|
|
self._save()
|
|
return True
|
|
return False
|
|
|
|
def list_tasks(self) -> list[dict]:
|
|
with self._lock:
|
|
return [{
|
|
"id": t.id, "type": t.type, "chain": t.chain,
|
|
"label": t.label, "interval_seconds": t.interval_seconds,
|
|
"last_run": t.last_run, "next_run": t.next_run,
|
|
"enabled": t.enabled,
|
|
} for t in self._tasks.values()]
|
|
|
|
def start(self):
|
|
if self._running:
|
|
return
|
|
self._running = True
|
|
self._thread = threading.Thread(target=self._run_loop, daemon=True, name="agent-scheduler")
|
|
self._thread.start()
|
|
logger.info("Agent scheduler started")
|
|
|
|
def stop(self):
|
|
self._running = False
|
|
|
|
def _run_loop(self):
|
|
while self._running:
|
|
now = time.time()
|
|
due: list[ScheduledTask] = []
|
|
with self._lock:
|
|
for t in self._tasks.values():
|
|
if t.enabled and t.next_run > 0 and now >= t.next_run:
|
|
due.append(t)
|
|
for task in due:
|
|
try:
|
|
self._execute_task(task)
|
|
task.last_run = now
|
|
task.next_run = now + task.interval_seconds
|
|
self._save()
|
|
except Exception as e:
|
|
logger.error(f"Task {task.id} failed: {e}")
|
|
time.sleep(60)
|
|
|
|
def _execute_task(self, task: ScheduledTask):
|
|
if task.type == "dca":
|
|
self._exec_dca(task)
|
|
elif task.type == "monitor":
|
|
self._exec_monitor(task)
|
|
elif task.type == "rotate":
|
|
self._exec_rotate(task)
|
|
|
|
def _exec_dca(self, task: ScheduledTask):
|
|
"""Execute a DCA task. If to_address is set, this is a REAL on-chain transfer
|
|
(not just intent). For EVM chains, uses wallet_sweep-like logic to broadcast.
|
|
For non-EVM, falls back to generating a destination wallet (legacy behavior).
|
|
"""
|
|
from wallet_engine.generator import get_generator
|
|
from core.vault import get_vault, WalletEntry
|
|
from core.config import cfg
|
|
vault = get_vault()
|
|
generator = get_generator(cfg.vault_password)
|
|
to_addr = task.params.get("to_address", "")
|
|
from_wallet_id = task.params.get("from_wallet_id", "")
|
|
label = task.label or f"dca_{task.chain}_{int(time.time())}"
|
|
|
|
# If both from_wallet_id and to_addr are set, this is a real DCA transfer.
|
|
# For EVM chains, broadcast via tx_broadcaster. For others, generate the
|
|
# destination wallet and rely on the user to fund it externally.
|
|
if from_wallet_id and to_addr:
|
|
from_wallet = vault.get(from_wallet_id)
|
|
if not from_wallet:
|
|
logger.warning(f"DCA: from_wallet {from_wallet_id} not found")
|
|
return
|
|
if from_wallet.chain != task.chain:
|
|
logger.warning(
|
|
f"DCA: wallet chain {from_wallet.chain} != task chain {task.chain}"
|
|
)
|
|
return
|
|
# For EVM: actually broadcast the transfer
|
|
from core.balance_fetcher import EVM_RPC
|
|
if task.chain in EVM_RPC:
|
|
# Lazy-import to avoid circular deps
|
|
from agent.mcp_server import _evm_sweep
|
|
result = _evm_sweep(from_wallet, to_addr, task.chain)
|
|
if result.get("status") == "broadcast":
|
|
logger.info(
|
|
f"DCA: Broadcast {task.amount} {task.chain} → {to_addr} "
|
|
f"tx={result.get('tx_hash')}"
|
|
)
|
|
else:
|
|
logger.error(f"DCA: Broadcast failed: {result.get('error')}")
|
|
return
|
|
# Non-EVM: just log the intent (no native broadcast implementation yet)
|
|
logger.info(
|
|
f"DCA: Intent {task.amount} {task.chain} → {to_addr} "
|
|
f"(non-EVM: not auto-broadcast, external signing required)"
|
|
)
|
|
return
|
|
|
|
# Legacy behavior: generate a destination wallet and let the user fund it
|
|
if to_addr:
|
|
logger.info(f"DCA: {task.amount} {task.chain} → {to_addr} (intent only)")
|
|
else:
|
|
wallet = generator.generate(task.chain, label=label, tags=["dca"])
|
|
entry = WalletEntry(
|
|
id=f"wp_dca_{task.chain}_{int(time.time())}",
|
|
chain=wallet.chain, address=wallet.address, label=wallet.label,
|
|
tags=wallet.tags, created_at=wallet.created_at,
|
|
encrypted_key=wallet.private_key_hex if wallet.encrypted else "",
|
|
encrypted=wallet.encrypted,
|
|
)
|
|
vault.put(entry)
|
|
logger.info(f"DCA: Generated new {task.chain} wallet → {wallet.address}")
|
|
|
|
def _exec_monitor(self, task: ScheduledTask):
|
|
addr = task.params.get("address", "")
|
|
chain = task.chain
|
|
if addr:
|
|
import asyncio
|
|
from agent.detector import scan_wallet
|
|
result = asyncio.run(scan_wallet(addr, chain))
|
|
if result.get("anomalies"):
|
|
logger.warning(f"Anomaly detected for {addr}: {result['anomalies']}")
|
|
webhook = task.params.get("webhook_url", "")
|
|
if webhook:
|
|
import httpx
|
|
try:
|
|
httpx.post(webhook, json={"event": "anomaly", "address": addr, "result": result}, timeout=10)
|
|
except Exception as e:
|
|
logger.error(f"Webhook failed: {e}")
|
|
|
|
def _exec_rotate(self, task: ScheduledTask):
|
|
from wallet_engine.generator import get_generator
|
|
from core.vault import get_vault, WalletEntry
|
|
from core.config import cfg
|
|
vault = get_vault()
|
|
wallet_id = task.params.get("wallet_id", "")
|
|
if not wallet_id:
|
|
return
|
|
old = vault.get(wallet_id)
|
|
if not old:
|
|
logger.warning(f"Rotate task: wallet {wallet_id} not found")
|
|
return
|
|
generator = get_generator(cfg.vault_password)
|
|
new = generator.generate(old.chain, label=f"scheduled_rotate_{int(time.time())}", tags=["rotated"])
|
|
entry = WalletEntry(
|
|
id=f"wp_sched_rot_{int(time.time())}",
|
|
chain=new.chain, address=new.address, label=new.label,
|
|
tags=new.tags, created_at=new.created_at,
|
|
encrypted_key=new.private_key_hex if new.encrypted else "",
|
|
encrypted=new.encrypted,
|
|
)
|
|
vault.put(entry)
|
|
vault.record_rotation(old.id, new.address, reason="scheduled_rotation")
|
|
logger.info(f"Scheduled rotation: {old.id} → {new.address}")
|
|
|
|
|
|
scheduler = AgentScheduler()
|
|
|
|
|
|
def add_dca_task(chain: str, amount: float, frequency: str = "weekly",
|
|
to_address: str = "", label: str = "") -> dict:
|
|
import secrets
|
|
freq_map = {"daily": 86400, "weekly": 604800, "biweekly": 1209600, "monthly": 2592000}
|
|
interval = freq_map.get(frequency, 604800)
|
|
now = time.time()
|
|
task = ScheduledTask(
|
|
id=f"dca_{secrets.token_hex(4)}",
|
|
type="dca",
|
|
chain=chain,
|
|
params={"amount": amount, "to_address": to_address},
|
|
interval_seconds=interval,
|
|
next_run=now + interval,
|
|
created_at=now,
|
|
label=label or f"DCA {amount} {chain} {frequency}",
|
|
)
|
|
scheduler.add_task(task)
|
|
return {
|
|
"task_id": task.id,
|
|
"type": "dca",
|
|
"chain": chain,
|
|
"amount": amount,
|
|
"frequency": frequency,
|
|
"next_run": task.next_run,
|
|
"label": task.label,
|
|
}
|
|
|
|
|
|
def add_monitor_task(wallet_id: str, webhook_url: str = "") -> dict:
|
|
import secrets
|
|
from core.vault import get_vault
|
|
vault = get_vault()
|
|
wallet = vault.get(wallet_id)
|
|
if not wallet:
|
|
return {"error": f"Wallet {wallet_id} not found"}
|
|
now = time.time()
|
|
task = ScheduledTask(
|
|
id=f"mon_{secrets.token_hex(4)}",
|
|
type="monitor",
|
|
chain=wallet.chain,
|
|
params={"address": wallet.address, "wallet_id": wallet_id, "webhook_url": webhook_url},
|
|
interval_seconds=3600,
|
|
next_run=now + 3600,
|
|
created_at=now,
|
|
label=f"Monitor {wallet.address[:12]}...",
|
|
)
|
|
scheduler.add_task(task)
|
|
return {
|
|
"task_id": task.id,
|
|
"type": "monitor",
|
|
"chain": wallet.chain,
|
|
"address": wallet.address,
|
|
"interval": "hourly",
|
|
"next_run": task.next_run,
|
|
}
|