108 lines
3.9 KiB
Python
Executable File
108 lines
3.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import json, os, signal, subprocess, time, urllib.request
|
|
from pathlib import Path
|
|
|
|
BASE = os.environ.get('GITEA_API_BASE', 'https://forge.alexanderwhitestone.com/api/v1')
|
|
TIMMY_TOKEN = (Path.home() / '.config' / 'gitea' / 'timmy-token').read_text().strip()
|
|
LOG = Path('/tmp/codeclaw-qwen-heartbeat.log')
|
|
LOCK = Path('/tmp/codeclaw-qwen-heartbeat.lock')
|
|
ACTIVE = Path('/tmp/codeclaw-qwen-active.json')
|
|
REPOS = [
|
|
'Timmy_Foundation/timmy-home',
|
|
'Timmy_Foundation/timmy-config',
|
|
'Timmy_Foundation/the-nexus',
|
|
'Timmy_Foundation/hermes-agent',
|
|
]
|
|
|
|
headers = {'Authorization': f'token {TIMMY_TOKEN}', 'Accept': 'application/json', 'Content-Type': 'application/json'}
|
|
|
|
def log(msg):
|
|
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
|
|
print(line)
|
|
with LOG.open('a') as f:
|
|
f.write(line + '\n')
|
|
|
|
def api(method, path, data=None):
|
|
req = urllib.request.Request(f"{BASE}{path}", headers=headers, method=method)
|
|
if data is not None:
|
|
req.data = json.dumps(data).encode()
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
raw = resp.read().decode()
|
|
return json.loads(raw) if raw else {}
|
|
|
|
|
|
def process_alive(pid):
|
|
try:
|
|
os.kill(int(pid), 0)
|
|
except (OSError, ProcessLookupError, ValueError, TypeError):
|
|
return False
|
|
return True
|
|
|
|
if LOCK.exists() and time.time() - LOCK.stat().st_mtime < 840:
|
|
log('SKIP: lock active')
|
|
raise SystemExit(0)
|
|
LOCK.write_text(str(os.getpid()))
|
|
try:
|
|
if ACTIVE.exists():
|
|
try:
|
|
active = json.loads(ACTIVE.read_text())
|
|
pid = active.get('pid')
|
|
if pid and process_alive(pid):
|
|
log(f'SKIP: worker still active for issue #{active.get("issue")}')
|
|
raise SystemExit(0)
|
|
ACTIVE.unlink(missing_ok=True)
|
|
except Exception:
|
|
pass
|
|
|
|
picked = None
|
|
for repo in REPOS:
|
|
owner, name = repo.split('/')
|
|
# candidate issues: assignee claw-code OR assigned-claw-code label
|
|
issues = api('GET', f'/repos/{owner}/{name}/issues?state=open&type=issues&limit=50&sort=created')
|
|
for i in reversed(issues):
|
|
assignees = [a['login'].lower() for a in (i.get('assignees') or [])]
|
|
labels = [l['name'] for l in i.get('labels', [])]
|
|
if 'claw-code' not in assignees and 'assigned-claw-code' not in labels:
|
|
continue
|
|
if 'claw-code-done' in labels:
|
|
continue
|
|
if 'claw-code-in-progress' in labels:
|
|
continue
|
|
picked = (repo, i['number'], i['title'])
|
|
break
|
|
if picked:
|
|
break
|
|
|
|
if not picked:
|
|
log('Heartbeat: no pending tasks')
|
|
raise SystemExit(0)
|
|
|
|
repo, issue_num, title = picked
|
|
owner, name = repo.split('/')
|
|
log(f'FOUND: {repo} #{issue_num} — {title}')
|
|
|
|
# add in-progress label if available
|
|
labels = api('GET', f'/repos/{owner}/{name}/labels?limit=100')
|
|
progress_id = next((l['id'] for l in labels if l['name'] == 'claw-code-in-progress'), None)
|
|
if progress_id:
|
|
try:
|
|
api('POST', f'/repos/{owner}/{name}/issues/{issue_num}/labels', {'labels': [progress_id]})
|
|
except Exception:
|
|
pass
|
|
|
|
api('POST', f'/repos/{owner}/{name}/issues/{issue_num}/comments', {
|
|
'body': f'🟠 Code Claw (OpenRouter qwen/qwen3.6-plus:free) picking up this issue via 15-minute heartbeat.\n\nTimestamp: {time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}'
|
|
})
|
|
|
|
worker_log = Path(f'/tmp/codeclaw-qwen-worker-{issue_num}.log')
|
|
fh = worker_log.open('a')
|
|
cmd = ['python3', str(Path.home() / '.timmy' / 'uniwizard' / 'codeclaw_qwen_worker.py'), repo, str(issue_num), title]
|
|
p = subprocess.Popen(cmd, stdout=fh, stderr=fh)
|
|
ACTIVE.write_text(json.dumps({'pid': p.pid, 'issue': issue_num, 'repo': repo}))
|
|
log(f'DISPATCHED: {repo} #{issue_num} (background PID {p.pid})')
|
|
finally:
|
|
try:
|
|
LOCK.unlink()
|
|
except Exception:
|
|
pass
|