mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 06:30:25 +00:00
The Python import namespace (coworker) and the live auth config strings are intentionally unchanged.
157 lines
5.7 KiB
Python
157 lines
5.7 KiB
Python
"""Launch the server with uvicorn. Used by the desktop GUI sidecar and `openworker-server`."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from ..config import load_config
|
|
from ..permissions import Mode
|
|
from ..secrets import state_dir
|
|
from .app import create_app
|
|
from .manager import SessionManager
|
|
|
|
|
|
def _exit_when_orphaned() -> None:
|
|
"""When launched as a desktop sidecar (`COWORKER_EXIT_WITH_PARENT=1`), exit if the parent
|
|
process dies — even on an abrupt kill (e.g. the Tauri dev watcher restarting the app, or a
|
|
crash) that skips the shell's graceful child-kill. Standalone `openworker-server` runs are
|
|
unaffected.
|
|
|
|
The GUI passes its own PID in `COWORKER_PARENT_PID`. Watching that explicit PID (not
|
|
getppid) is what makes this work under PyInstaller onefile, where this process is a
|
|
*grandchild* of the GUI — the bootloader sits in between, so getppid() points at the
|
|
bootloader and a re-parenting check never fires when the GUI dies (the bug that leaked
|
|
a server pair on every app quit).
|
|
|
|
POSIX: poll the PID with kill(pid, 0). Windows: no re-parenting semantics at all, so
|
|
block on a process handle and exit the moment it signals (i.e. the parent exited).
|
|
"""
|
|
if os.environ.get("COWORKER_EXIT_WITH_PARENT") != "1":
|
|
return
|
|
import threading
|
|
|
|
try:
|
|
parent = int(os.environ.get("COWORKER_PARENT_PID") or 0)
|
|
except ValueError:
|
|
parent = 0
|
|
parent = parent or os.getppid() # standalone fallback: our direct spawner
|
|
|
|
if sys.platform == "win32":
|
|
_watch_parent_windows(parent)
|
|
return
|
|
|
|
import time
|
|
|
|
original_ppid = os.getppid()
|
|
|
|
def watch() -> None:
|
|
while True:
|
|
time.sleep(1.5)
|
|
try:
|
|
os.kill(parent, 0) # liveness probe only; signal 0 delivers nothing
|
|
except ProcessLookupError:
|
|
os._exit(0)
|
|
except PermissionError:
|
|
pass # alive, but owned by someone else (shouldn't happen) — keep waiting
|
|
# Secondary signal: our direct parent died (covers PID-reuse edge cases).
|
|
if os.getppid() != original_ppid:
|
|
os._exit(0)
|
|
|
|
threading.Thread(target=watch, daemon=True).start()
|
|
|
|
|
|
def _watch_parent_windows(parent: int) -> None:
|
|
"""Block on a handle to the parent process; exit only when it actually terminates.
|
|
|
|
Best-effort — any failure leaves the parent's RunEvent::ExitRequested kill as the primary
|
|
cleanup path. Two correctness points that bit us before:
|
|
- `OpenProcess` returns a 64-bit HANDLE; ctypes defaults the return type to a 32-bit int,
|
|
which truncates the handle to garbage. Declare restype/argtypes so the handle is valid.
|
|
- Only `os._exit` on WAIT_OBJECT_0 (the parent genuinely died). A bad handle yields
|
|
WAIT_FAILED immediately — treating that as "parent died" would kill a perfectly healthy
|
|
server seconds after startup (exactly the freeze we saw)."""
|
|
import ctypes
|
|
import threading
|
|
from ctypes import wintypes
|
|
|
|
SYNCHRONIZE = 0x0010_0000
|
|
INFINITE = 0xFFFF_FFFF
|
|
WAIT_OBJECT_0 = 0x0000_0000
|
|
|
|
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
kernel32.WaitForSingleObject.restype = wintypes.DWORD
|
|
kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
|
|
|
handle = kernel32.OpenProcess(SYNCHRONIZE, False, parent)
|
|
if not handle:
|
|
return
|
|
|
|
def watch() -> None:
|
|
if kernel32.WaitForSingleObject(handle, INFINITE) == WAIT_OBJECT_0:
|
|
os._exit(0)
|
|
|
|
threading.Thread(target=watch, daemon=True).start()
|
|
|
|
|
|
def build_app(workspace: str | None, model: str, mode: str):
|
|
manager = SessionManager(
|
|
workspace=Path(workspace).expanduser().resolve() if workspace else None,
|
|
data_dir=state_dir(),
|
|
model=model,
|
|
mode=Mode(mode),
|
|
)
|
|
return create_app(manager)
|
|
|
|
|
|
def _ensure_ca_bundle() -> None:
|
|
"""Point SSL at certifi's CA bundle if the interpreter has none configured. macOS framework
|
|
Python ships without a usable system trust store for `aiohttp` (it builds an `ssl` context with
|
|
no CAs), so the Slack Socket-Mode client fails with CERTIFICATE_VERIFY_FAILED. `httpx`/`requests`
|
|
bundle certifi already; aiohttp honours the SSL_CERT_FILE env var, so set it once at startup.
|
|
"""
|
|
if os.environ.get("SSL_CERT_FILE"):
|
|
return
|
|
try:
|
|
import certifi
|
|
|
|
os.environ["SSL_CERT_FILE"] = certifi.where()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def main(argv=None) -> None:
|
|
_ensure_ca_bundle()
|
|
cfg = load_config() # global config supplies defaults
|
|
parser = argparse.ArgumentParser(prog="openworker-server")
|
|
parser.add_argument("--cwd", default=None, help="optional seed/default workspace")
|
|
parser.add_argument("--model", default=cfg.model)
|
|
parser.add_argument(
|
|
"--mode",
|
|
default=cfg.mode,
|
|
choices=["discuss", "plan", "interactive", "auto"],
|
|
)
|
|
parser.add_argument("--host", default=cfg.host)
|
|
parser.add_argument("--port", type=int, default=cfg.port)
|
|
args = parser.parse_args(argv)
|
|
|
|
# Publish the ACTUAL bound port so loopback URLs (the managed-OAuth callback)
|
|
# target this process, not config.port. The desktop shell runs the sidecar on
|
|
# a random free port (to coexist with a hand-run server on 8765), so the
|
|
# managed-connect redirect must follow the real port, not the 8765 default.
|
|
os.environ["COWORKER_PORT"] = str(args.port)
|
|
|
|
import uvicorn
|
|
|
|
_exit_when_orphaned()
|
|
app = build_app(args.cwd, args.model, args.mode)
|
|
uvicorn.run(app, host=args.host, port=args.port)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|