Files
2026-09-22 11:18:23 -04:00

278 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""Durable, single-owner controller for one NestQuest OpenCode tick at a time."""
from __future__ import annotations
import argparse
import fcntl
import json
import os
from pathlib import Path
import re
import selectors
import signal
import sqlite3
import subprocess
import sys
import time
import urllib.request
DEFAULT_PROJECT = Path("/mnt/ai-storage/projects/NestQuest")
DEFAULT_STATE = Path.home() / ".local/state/nestquest-controller"
STATE_RE = re.compile(r"(?m)^ORCHESTRATOR_STATE: (CONTINUE|QUIESCENT|BLOCKED)\s*$")
MAX_TEXT = 64_000
MAX_LINE = 4_000_000
CTXD_PROJECT = "nestquest-house-chaos-coordination"
CTXD_FILE = "OPENCODE-ORCHESTRATOR-PROMPT.MD"
def check_ctxd() -> int:
"""Use OpenCode's configured endpoint, never an unrelated host DB."""
config = json.loads((Path.home() / ".config/opencode/opencode.json").read_text())
mcp = config["mcp"]["ctxd"]
configured_url = mcp["url"].rstrip("/")
if not configured_url.endswith("/mcp"):
raise RuntimeError("CTXD MCP URL must end in /mcp")
endpoint = configured_url.removesuffix("/mcp")
url = f"{endpoint}/projects/{CTXD_PROJECT}/files/{CTXD_FILE}"
with urllib.request.urlopen(
urllib.request.Request(url, headers=mcp["headers"]), timeout=8
) as response:
record = json.load(response)
if (
record.get("project_id") != CTXD_PROJECT
or record.get("file_path") != CTXD_FILE
or "CONTROLLER_TICK" not in record.get("content", "")
):
raise RuntimeError("CTXD returned the wrong NestQuest prompt")
return int(record["version"])
def interactive_nestquest_session(
project: Path,
proc_root: Path = Path("/proc"),
database: Path = Path.home() / ".local/share/opencode/opencode.db",
) -> bool:
"""Avoid racing a user-owned OpenCode TUI for the same repository."""
try:
db = sqlite3.connect(f"file:{database}?mode=ro", uri=True)
except sqlite3.Error as error:
raise RuntimeError("OpenCode session database is unavailable") from error
try:
for entry in proc_root.iterdir():
if not entry.name.isdecimal() or int(entry.name) == os.getpid():
continue
try:
args = (entry / "cmdline").read_bytes().split(b"\0")
args = [a.decode(errors="replace") for a in args if a]
if not args or Path(args[0]).name != "opencode":
continue
if len(args) > 1 and args[1] in ("run", "debug"):
continue
session = None
for flag in ("-s", "--session"):
if flag in args and args.index(flag) + 1 < len(args):
session = args[args.index(flag) + 1]
break
if session:
row = db.execute("SELECT directory FROM session WHERE id=?", (session,)).fetchone()
if row and Path(row[0]).resolve() == project.resolve():
return True
elif "--dir" in args and args.index("--dir") + 1 < len(args):
if Path(args[args.index("--dir") + 1]).resolve() == project.resolve():
return True
elif (entry / "cwd").resolve() == project.resolve():
return True
except sqlite3.Error as error:
raise RuntimeError("OpenCode session lookup failed") from error
except TypeError as error:
raise RuntimeError("OpenCode session directory is invalid") from error
except (OSError, PermissionError):
continue
finally:
db.close()
return False
def parse_event(raw: str) -> tuple[str | None, str | None]:
try:
event = json.loads(raw)
except json.JSONDecodeError:
return None, None
if not isinstance(event, dict):
return None, None
kind = event.get("type")
part = event.get("part") or {}
if kind == "text" and isinstance(part, dict):
return part.get("text"), None
if kind == "error":
error = event.get("error") or {}
known = {"ProviderAuthError", "UnknownError", "MessageOutputLengthError", "MessageAbortedError", "StructuredOutputError", "ContextOverflowError", "APIError"}
name = error.get("name") if isinstance(error, dict) else None
return None, name if name in known else "OpenCode error"
return None, None
def run_tick(command: list[str], env: dict[str, str], prompt: str, timeout: int) -> tuple[int, str, str | None]:
process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env=env,
start_new_session=True,
)
assert process.stdin is not None and process.stdout is not None
try:
process.stdin.write(prompt.encode())
except BrokenPipeError:
pass
finally:
process.stdin.close()
selector = selectors.DefaultSelector()
selector.register(process.stdout, selectors.EVENT_READ)
deadline = time.monotonic() + timeout
buffer = b""
texts: list[str] = []
last_error: str | None = None
diagnostic_lines = 0
try:
while selector.get_map():
if time.monotonic() >= deadline:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait()
return 124, "".join(texts)[-MAX_TEXT:], "controller tick exceeded documented timeout"
for key, _ in selector.select(timeout=1):
chunk = os.read(key.fd, 65536)
if not chunk:
selector.unregister(key.fileobj)
continue
buffer += chunk
while b"\n" in buffer:
line, buffer = buffer.split(b"\n", 1)
text, error = parse_event(line.decode(errors="replace"))
if not line.lstrip().startswith(b"{"):
diagnostic_lines += 1
if text:
texts.append(text)
if sum(map(len, texts)) > MAX_TEXT:
texts = ["".join(texts)[-MAX_TEXT:]]
if error:
last_error = error
if len(buffer) > MAX_LINE:
buffer = b"" # A tool-output event is not a controller verdict.
if buffer:
text, error = parse_event(buffer.decode(errors="replace"))
if text:
texts.append(text)
if error:
last_error = error
code = process.wait()
if code and last_error is None and diagnostic_lines:
last_error = f"OpenCode emitted {diagnostic_lines} non-JSON diagnostic lines"
return code, "".join(texts)[-MAX_TEXT:], last_error
finally:
selector.close()
process.stdout.close()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project", type=Path, default=DEFAULT_PROJECT)
parser.add_argument("--state-dir", type=Path, default=DEFAULT_STATE)
parser.add_argument("--once", action="store_true", help="run only one model turn")
parser.add_argument("--check", action="store_true", help="validate paths without calling a model")
parser.add_argument("--check-interactive", action="store_true", help="report an open NestQuest TUI without calling a model")
parser.add_argument("--tick-timeout", type=int, default=6 * 3600)
args = parser.parse_args()
project = args.project.resolve()
control_home = Path(__file__).resolve().parent.parent
config_dir = control_home / ".opencode"
binary = Path(os.environ.get("OPENCODE_BIN", "/home/overseer/.opencode/bin/opencode"))
if not (project.is_dir() and config_dir.is_dir() and binary.is_file()):
parser.error("project, agent configuration, or OpenCode binary is missing")
if args.check:
try:
ctxd_version = check_ctxd()
except (OSError, KeyError, ValueError, RuntimeError) as error:
print(f"CTXD preflight failed: {type(error).__name__}", file=sys.stderr)
return 1
print(f"ready: project={project} configuration={config_dir} binary={binary} ctxd_prompt_version={ctxd_version}")
return 0
if args.check_interactive:
try:
active = interactive_nestquest_session(project)
except RuntimeError:
print("OpenCode session state unavailable", file=sys.stderr)
return 77
print("interactive NestQuest session open" if active else "no interactive NestQuest session")
return 0
args.state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(args.state_dir, 0o700)
with (args.state_dir / "controller.lock").open("w") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print("another NestQuest controller is running", file=sys.stderr)
return 75
command = [str(binary), "run", "--pure", "--dir", str(project), "--agent", "orchestrator", "--format", "json", "--title", "NestQuest controller tick"]
env = os.environ.copy()
env["OPENCODE_CONFIG_DIR"] = str(config_dir)
env["NESTQUEST_CONTROL_HOME"] = str(control_home)
failures = 0
while True:
try:
interactive = interactive_nestquest_session(project)
except RuntimeError:
print("OpenCode session state unavailable; waiting", file=sys.stderr, flush=True)
if args.once:
return 77
time.sleep(60)
continue
if interactive:
print("interactive NestQuest session still open; waiting", flush=True)
if args.once:
return 75
time.sleep(60)
continue
prompt = (
"Run one NestQuest controller tick. Read CTXD and Maestro afresh, "
"reconcile Git and PR state, then execute the next eligible action. "
"Use NESTQUEST_CONTROL_HOME for role launch scripts. "
"Return a final ORCHESTRATOR_STATE line as your agent instructions require."
)
status, answer, error = run_tick(command, env, prompt, args.tick_timeout)
states = STATE_RE.findall(answer)
state = states[-1] if status == 0 and error is None and states else "ERROR"
if state == "CONTINUE":
failures = 0
elif state == "QUIESCENT":
failures = 0
elif state == "BLOCKED":
failures = 0
else:
failures += 1
summary = {"time": int(time.time()), "state": state, "exit_code": status, "error": error, "failure_count": failures, "answer_tail": answer[-4000:]}
target = args.state_dir / "last-run.json"
temporary = args.state_dir / "last-run.json.tmp"
temporary.write_text(json.dumps(summary, indent=2) + "\n")
os.chmod(temporary, 0o600)
temporary.replace(target)
print(f"tick state={state} exit={status} failures={failures}", flush=True)
if args.once:
return 0 if state in ("CONTINUE", "QUIESCENT") else 77
if state == "BLOCKED" or failures >= 3:
print("controller blocked; inspect last-run.json", file=sys.stderr)
return 77
time.sleep(300 if state == "QUIESCENT" else min(120, 10 * 2**failures))
if __name__ == "__main__":
raise SystemExit(main())