diff --git a/README.md b/README.md index fdb2d8f..2aabc1f 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,13 @@ ### News -- **2026-07-31**: v0.0.6 released — side-effect-gated recovery (checkpointed halts with structured `InteractionRequest`), uncertain-effect reporting for failed outbound calls, centralized logging with an independent daemon log level, session-bound LeapBoard analysis, platform-neutral gateway validators, and end-to-end architecture contract tests with the CI gate restored. +- **2026-08-06**: v0.0.8 released — Cross-platform Windows support (DaemonTransport protocol with TCP loopback IPC), real journey test layer with cassette-backed CI (6 e2e journeys, cost-bounded), community Windows fixes (@fanqiNO1). 1,540 tests. +- **2026-08-06**: v0.0.7 released — 1M-class context windows end-to-end, self-calibrating token estimator, internal-defect failure category, concurrent-TUI session identity isolation. 1,442 tests.
Previous releases +- **2026-07-31**: v0.0.6 released — side-effect-gated recovery (checkpointed halts with structured `InteractionRequest`), uncertain-effect reporting for failed outbound calls, centralized logging with an independent daemon log level, session-bound LeapBoard analysis, platform-neutral gateway validators, and end-to-end architecture contract tests with the CI gate restored. - **2026-07-28**: v0.0.5 released — adaptive-depth execution for long-horizon tasks, built-in coding tools, per-session daemon concurrency, improved TUI stability, and hardened leapd recovery/status diagnostics. - **2026-07-16**: **LeapBoard** — general-purpose monitoring web dashboard (Watch→Finding + Server-Driven UI); `/board` entry, live session analysis, and finance/sentiment/research templates. - **2026-07-16**: v0.0.4 released — protocol-driven recovery coordinator with budget-constrained strategies, unified error classification, checkpoint-based cross-turn resumption, structured audit trail, and tool execution idempotency ledger. diff --git a/src/leapflow/cli/commands/host.py b/src/leapflow/cli/commands/host.py index f1382b3..15fdcc1 100644 --- a/src/leapflow/cli/commands/host.py +++ b/src/leapflow/cli/commands/host.py @@ -94,7 +94,7 @@ def _read_pid_file() -> Optional[int]: if not pid_file.exists(): return None try: - pid = int(pid_file.read_text().strip()) + pid = int(pid_file.read_text(encoding="utf-8").strip()) # Check if process is alive os.kill(pid, 0) return pid @@ -111,7 +111,7 @@ def _write_pid_file(pid: int) -> None: """Write PID to daemon pid file.""" pid_file = _daemon_pid_file() pid_file.parent.mkdir(parents=True, exist_ok=True) - pid_file.write_text(str(pid)) + pid_file.write_text(str(pid), encoding="utf-8") def _remove_pid_file() -> None: @@ -279,7 +279,7 @@ async def _cmd_start() -> int: creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) start_new_session = False - with open(log_file, "a") as lf: + with open(log_file, "a", encoding="utf-8") as lf: proc = subprocess.Popen( [sys.executable, "-c", daemon_script], stdout=lf, diff --git a/src/leapflow/daemon/_transport.py b/src/leapflow/daemon/_transport.py index 30510f0..c4aa7dd 100644 --- a/src/leapflow/daemon/_transport.py +++ b/src/leapflow/daemon/_transport.py @@ -82,7 +82,7 @@ def _port_path(self, runtime_dir: Path) -> Path: def _read_port(self, runtime_dir: Path) -> int: port_path = self._port_path(runtime_dir) - return int(port_path.read_text().strip()) + return int(port_path.read_text(encoding="utf-8").strip()) async def start_server( self, @@ -98,7 +98,7 @@ async def start_server( addr = server.sockets[0].getsockname() port = addr[1] port_path = self._port_path(runtime_dir) - port_path.write_text(str(port)) + port_path.write_text(str(port), encoding="utf-8") return server async def connect(self, runtime_dir: Path) -> Tuple[asyncio.StreamReader, asyncio.StreamWriter]: diff --git a/src/leapflow/daemon/lifecycle.py b/src/leapflow/daemon/lifecycle.py index 85ce0eb..ca3d04d 100644 --- a/src/leapflow/daemon/lifecycle.py +++ b/src/leapflow/daemon/lifecycle.py @@ -159,14 +159,14 @@ def write_pid_file(runtime_dir: Path, pid: Optional[int] = None) -> None: """Write the daemon PID file and metadata.""" runtime_dir.mkdir(parents=True, exist_ok=True) actual_pid = pid or os.getpid() - (runtime_dir / "leapd.pid").write_text(str(actual_pid)) + (runtime_dir / "leapd.pid").write_text(str(actual_pid), encoding="utf-8") meta = { "pid": actual_pid, "start_time": time.time(), "version": "1", } - (runtime_dir / "leapd.json").write_text(json.dumps(meta)) + (runtime_dir / "leapd.json").write_text(json.dumps(meta), encoding="utf-8") logger.info("daemon: wrote pid=%d to %s", actual_pid, runtime_dir / "leapd.pid") @@ -202,6 +202,8 @@ def send_signal(runtime_dir: Path, sig: DaemonSignal = DaemonSignal.SIGTERM) -> return False try: os.kill(pid, sig.value) + if sys.platform == "win32": + _wait_process_exit(pid, timeout_ms=5000) return True except OSError: return False @@ -291,6 +293,8 @@ def spawn_daemon(settings: object, *, mock_host: bool = False) -> subprocess.Pop if mock_host: command.append("--mock-host") command.extend(["daemon", "serve", "--internal"]) + env = os.environ.copy() + env.setdefault("PYTHONIOENCODING", "utf-8") log_file = open(log_path, "ab") try: if sys.platform == "win32": # pragma: no cover - platform specific @@ -304,6 +308,7 @@ def spawn_daemon(settings: object, *, mock_host: bool = False) -> subprocess.Pop stderr=subprocess.STDOUT, start_new_session=False, creationflags=creationflags, + env=env, ) else: proc = subprocess.Popen( @@ -313,6 +318,7 @@ def spawn_daemon(settings: object, *, mock_host: bool = False) -> subprocess.Pop stderr=subprocess.STDOUT, start_new_session=True, close_fds=True, + env=env, ) finally: log_file.close() @@ -345,7 +351,7 @@ def _wait_until_stopped(runtime_dir: Path, *, deadline: float, interval_s: float def _read_pid(path: Path) -> Optional[int]: """Read PID from file, returning None if missing/invalid.""" try: - return int(path.read_text().strip()) + return int(path.read_text(encoding="utf-8").strip()) except (OSError, ValueError): return None @@ -353,11 +359,31 @@ def _read_pid(path: Path) -> Optional[int]: def _read_meta(path: Path) -> Optional[dict]: """Read daemon metadata JSON.""" try: - return json.loads(path.read_text()) + return json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError, ValueError): return None +def _wait_process_exit(pid: int, timeout_ms: int = 5000) -> None: + """Block until the process fully exits (Windows only). Best-effort.""" + if sys.platform != "win32": + return + try: + import ctypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + SYNCHRONIZE = 0x00100000 + handle = kernel32.OpenProcess(SYNCHRONIZE, False, pid) + if not handle: + return + try: + kernel32.WaitForSingleObject(handle, timeout_ms) + finally: + kernel32.CloseHandle(handle) + except (OSError, AttributeError): + pass + + # Windows kernel32 constants for the exit-code liveness probe below. _PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 _STILL_ACTIVE = 259 diff --git a/src/leapflow/skills/index.py b/src/leapflow/skills/index.py index 0408951..9b0f435 100644 --- a/src/leapflow/skills/index.py +++ b/src/leapflow/skills/index.py @@ -208,7 +208,7 @@ def _load_from_snapshot(self) -> Optional[List[SkillEntry]]: if not self._snapshot_path.exists(): return None try: - data = json.loads(self._snapshot_path.read_text()) + data = json.loads(self._snapshot_path.read_text(encoding="utf-8")) # Convert list[str] back to tuple for frozen dataclass entries: List[SkillEntry] = [] for raw in data: @@ -225,7 +225,7 @@ def _save_snapshot(self, entries: List[SkillEntry]) -> None: try: self._skills_dir.mkdir(parents=True, exist_ok=True) data = [dataclasses.asdict(e) for e in entries] - self._snapshot_path.write_text(json.dumps(data, ensure_ascii=False)) + self._snapshot_path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") except Exception: pass # Non-critical — next scan will rebuild diff --git a/src/leapflow/storage/duckdb_connect.py b/src/leapflow/storage/duckdb_connect.py index 2127c97..3d34a46 100644 --- a/src/leapflow/storage/duckdb_connect.py +++ b/src/leapflow/storage/duckdb_connect.py @@ -15,6 +15,7 @@ import logging import os +import sys import time from pathlib import Path from typing import Optional @@ -25,8 +26,8 @@ LOCK_KEYWORDS = frozenset({"lock", "locked", "exclusive", "cannot set lock"}) -_CONNECT_RETRIES = int(os.getenv("LEAPFLOW_DB_CONNECT_RETRIES", "3")) -_CONNECT_BACKOFF_S = float(os.getenv("LEAPFLOW_DB_CONNECT_BACKOFF_S", "0.5")) +_CONNECT_RETRIES = int(os.getenv("LEAPFLOW_DB_CONNECT_RETRIES", "5" if sys.platform == "win32" else "3")) +_CONNECT_BACKOFF_S = float(os.getenv("LEAPFLOW_DB_CONNECT_BACKOFF_S", "1.0" if sys.platform == "win32" else "0.5")) class DatabaseLockedError(RuntimeError): diff --git a/src/leapflow/version.py b/src/leapflow/version.py index 929f491..e677663 100644 --- a/src/leapflow/version.py +++ b/src/leapflow/version.py @@ -1,3 +1,3 @@ """Version information for leapflow.""" -__version__ = "0.0.7+main" +__version__ = "0.0.8+main"