mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-12 15:20:19 +00:00
OpenWorker: initial import
Imported from andrewyng/aisuite@1b4bbf303e (contents of its platform/ directory, hoisted to the repo root). Development history prior to this commit lives in that repository. Co-authored-by: Devika <devikaverma11@gmail.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
"""Launch the server with uvicorn. Used by the desktop GUI sidecar and `coworker-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 `coworker-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="coworker-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()
|
||||
Reference in New Issue
Block a user