fix(windows): guard Unix-only APIs so the server and deriver start on native Windows

Honcho runs fine on Linux/macOS and under WSL/Docker, but cannot start on a
native Windows host. Two Unix-only APIs are used unconditionally.

1. src/telemetry/reasoning_traces.py imported fcntl at module scope. fcntl does
   not exist on Windows, so importing it raises ModuleNotFoundError and takes
   down the whole app - even though its only use is advisory locking around
   *optional* trace logging (REASONING_TRACES_FILE, off by default).

   Rather than skipping the lock on Windows (which would let the API server and
   deriver interleave writes into the shared traces file), locking is now done
   through a small _locked() context manager: fcntl.flock on POSIX,
   msvcrt.locking on Windows, unlocked fallback only if neither exists.

2. src/deriver/queue_manager.py called loop.add_signal_handler(), which raises
   NotImplementedError on Windows. That exception's str() is empty, so the
   failure surfaced only as 'Error in main: ' with no message.

   Rather than skipping signal handling on Windows (which would leave no path to
   set shutdown_event, stop the schedulers, or drain active_tasks), Windows now
   registers the same shutdown via signal.signal() - which Windows does support
   for SIGINT/SIGTERM - bridged onto the loop with call_soon_threadsafe.

Unix behaviour is unchanged: both platforms take their native primitive.

Verified on Windows 11 / Python 3.11.16 / PostgreSQL 18.6 / pgvector 0.8.6:
API serves, deriver drains the queue and generates conclusions, dialectic chat
returns at reasoning_level=medium. Concurrent-write test of _locked() with 6
processes x 40 writes produced 240/240 valid JSONL records, 0 malformed.
ruff check and ruff format --check both pass.
This commit is contained in:
DesarrolloProsis 2026-08-25 22:06:53 -06:00
parent 9380bf2753
commit fcf0be76f8
2 changed files with 71 additions and 11 deletions

View File

@ -2,6 +2,7 @@ import asyncio
import contextlib
import random
import signal
import sys
import time
from asyncio import Task
from collections.abc import Iterable, Sequence
@ -200,14 +201,30 @@ class QueueManager:
"""Setup signal handlers, initialize client, and start the main polling loop"""
logger.debug(f"Initializing QueueManager with {self.workers} workers")
# Set up signal handlers
# Set up signal handlers.
# loop.add_signal_handler() is not implemented on Windows (it raises
# NotImplementedError, whose str() is empty). Fall back to signal.signal(),
# which Windows does support for SIGINT/SIGTERM, so Ctrl+C and a service
# stop still trigger the same graceful shutdown path.
loop = asyncio.get_running_loop()
signals = (signal.SIGTERM, signal.SIGINT)
for sig in signals:
loop.add_signal_handler(
sig, lambda s=sig: asyncio.create_task(self.shutdown(s))
)
logger.debug("Signal handlers registered")
if sys.platform != "win32":
for sig in signals:
loop.add_signal_handler(
sig, lambda s=sig: asyncio.create_task(self.shutdown(s))
)
logger.debug("Signal handlers registered")
else:
def _win_signal_handler(signum: int, _frame: object) -> None:
# Runs in the main thread outside the loop; hand off thread-safely.
loop.call_soon_threadsafe(
lambda: asyncio.create_task(self.shutdown(signal.Signals(signum)))
)
for sig in signals:
signal.signal(sig, _win_signal_handler)
logger.debug("Signal handlers registered via signal.signal (Windows)")
# Start the reconciler scheduler
try:

View File

@ -4,11 +4,25 @@ Utility for logging traces from LLM calls.
This module provides structured JSONL logging of LLM inputs/outputs.
"""
import fcntl
import contextlib
import json
import os
import time
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any
from typing import IO, Any
try: # POSIX
import fcntl
except ImportError: # pragma: no cover - platform dependent
fcntl = None # type: ignore[assignment]
try: # Windows
import msvcrt
except ImportError: # pragma: no cover - platform dependent
msvcrt = None # type: ignore[assignment]
from pydantic import BaseModel
@ -19,6 +33,37 @@ from src.config import (
)
@contextmanager
def _locked(f: IO[str]) -> Iterator[None]:
"""Exclusively lock an open file for the duration of the block.
Multiple processes (API server and deriver) append to the same traces file, so
writes must be serialized. POSIX uses fcntl.flock; Windows uses msvcrt.locking,
which locks a byte range from the current offset. If neither is available the
write still proceeds unlocked rather than losing the trace.
"""
if fcntl is not None:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
elif msvcrt is not None:
f.seek(0, os.SEEK_END)
try:
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
except OSError: # lock unavailable after retries — do not drop the trace
yield
return
try:
yield
finally:
with contextlib.suppress(OSError):
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
else: # pragma: no cover - no locking primitive available
yield
def get_reasoning_traces_file_path() -> Path | None:
"""Get the traces file path from settings."""
if settings.REASONING_TRACES_FILE:
@ -97,7 +142,5 @@ def log_reasoning_trace(
trace_entry["output"]["tool_calls"] = response.tool_calls_made
# Use file locking to handle concurrent writes from multiple processes
with open(traces_file, "a") as f:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
with open(traces_file, "a") as f, _locked(f):
f.write(json.dumps(trace_entry) + "\n")
fcntl.flock(f.fileno(), fcntl.LOCK_UN)