Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 15 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,21 @@ jobs:
# NOTE: the python job runs on pull_request only (not push) because the
# windows-latest runner intermittently flakes on
# test_stop_after_restart_kills_runner_and_workload (pid teardown timing after
# stop_sync). It also only runs on Windows for now: the ubuntu python matrix
# failed on `print('x')`-style jobs being marked 'failed' — the runner spawns
# workloads via `subprocess.Popen(shell=True)` with a command string built by
# `subprocess.list2cmdline` (a Windows-only escaper), which produces invalid
# POSIX shell quoting (bash: "syntax error near unexpected token `('"). The
# fix is to make the runner POSIX-safe (shlex) — not a test-timing issue.
# Re-enable ubuntu in the matrix once that's fixed.
# stop_sync). Linux and macOS are covered too: the suite builds workload
# command strings with tests/shellcmd.py (shlex.join on POSIX, list2cmdline on
# Windows) so the platform shell executes them correctly everywhere. macOS is
# limited to one Python version to limit runner cost; Windows and Linux cover
# both.
python:
if: github.event_name == 'pull_request'
strategy:
fail-fast: false
matrix:
os: [windows-latest]
os: [windows-latest, ubuntu-latest, macos-latest]
python: ['3.11', '3.12']
exclude:
- os: macos-latest
python: '3.11'
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
Expand All @@ -38,15 +39,15 @@ jobs:
- name: Smoke installed wheel
shell: bash
run: |
wheel=$(find dist -maxdepth 1 -name '*.whl' -print -quit)
test -n "$wheel"
uv run --isolated --no-project --with "$wheel" python -c "import vanth; print(vanth.__file__)"
wheels=(dist/*.whl)
test -e "${wheels[0]}"
uv run --isolated --no-project --with "${wheels[0]}" python -c "import vanth; print(vanth.__file__)"
- name: Smoke bundled monitor console script
shell: bash
run: |
wheel=$(find dist -maxdepth 1 -name '*.whl' -print -quit)
test -n "$wheel"
uv run --isolated --no-project --with "$wheel" vanth-monitor --version
wheels=(dist/*.whl)
test -e "${wheels[0]}"
uv run --isolated --no-project --with "${wheels[0]}" vanth-monitor --version

go:
strategy:
Expand Down
75 changes: 75 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,81 @@

All notable changes to Vanth are documented here.

## 1.9.1 - 2026-09-11

### POSIX Python CI + portability fixes

Enables the Python test matrix on `ubuntu-latest` and `macos-latest` alongside
Windows (`.github/workflows/ci.yml`). Getting the suite green there surfaced
the fixes below.

- **Test command builders are POSIX-correct.** A shared `tests/shellcmd.py`
quotes workload command strings with `shlex.join` on POSIX and
`subprocess.list2cmdline` on Windows, so `python -c "print('x')"`-style jobs
run under `sh`/`bash` instead of failing with a shell syntax error. The dev
scripts follow the same rule.
- **`job_wait` returns the earliest matching signal.** When several signals
match (terminal event, a `metric_ge` threshold crossed, or
`return_progress`), the earliest by event sequence wins instead of always
preferring the terminal event. A metric/progress signal that precedes
completion is returned first; a terminal event still wins once nothing
earlier is pending. Metric candidates honour `since_event_id` (a threshold
already returned at or before the cursor is not returned again, so a caller
that advances the cursor still reaches the terminal event) and every
threshold is considered, not just the first in dictionary order.
- **Codex Desktop pipe hardening.** A peer that closes the connection is now
reported consistently ("closed the connection") whether detected on read or
write, and `close()` shuts a socket down before closing it so a reader
blocked in `recv()` wakes promptly on POSIX (previously it could add ~2s to a
timed-out call).
- **Failure-streak ordering and counting.** The `on_failure` policy persists
the updated `failure_streak` before emitting the `failure_threshold` event,
and counts every failed execution in the interval between two event-sequence
watermarks (bounded at the latest failure read, so a concurrent failure is
neither skipped nor double-counted; a fast restart with backoff 0 no longer
undercounts; state written by 1.9.0 is migrated, so an upgrade does not
recount old failures). The reaction is marked complete only after it
succeeds, so a daemon crash or action error retries it instead of dropping
it — at-least-once delivery: a crash between the side effect and the marker
save can repeat it, which is harmless for the built-in actions (disable
excludes the job from the scan, run_job refuses a busy target, alerts are
advisory).
- **macOS artifact materialization.** Directory materialization uses the
dev/inode-checked plain-path fallback on macOS instead of `/dev/fd`, which is
unreliable for creating nested entries under a directory fd; the destination
parent and staging descriptors are re-verified immediately before publication
and the operation fails closed if the parent was persistently swapped
mid-write. A transient swap restored before the check, or a staging-directory
replacement under an unchanged parent, is still not caught — closing that
requires descriptor-relative tree construction on macOS.
- **Daemon startup.** The HTTP server no longer calls `socket.getfqdn` at
bind time — a reverse-DNS lookup that can block for seconds (or hang) on
locked-down networks and stall startup past client timeouts.
- **Launch claims.** A new launch claim clears the previous run's
`worker_pid`, so stale-claim recovery can no longer skip an abandoned claim
whose old runner pid is still momentarily visible.
- **Idle reaper.** A healthy Desktop wake relay whose activity cadence is
coarser than the watchdog's sampling interval is no longer idle-reaped (the
freshness window is the idle threshold, not the sampling interval), and the
effective idle timeout is measured from the last activity rather than
restarting a second window when the freshness window expires.
- **Event write resilience.** Structured-event writes retry further under lock
contention instead of being dropped (Windows CI lost reader events under a
concurrent job burst).
- **Orphaned-MCP reaping is safer.** Detection matches the actual Vanth MCP
entrypoint only — the `vanth` console script with no CLI subcommand, or
`<python> -m vanth.server` (with interpreter options tolerated) — and rejects
lookalikes such as `python unrelated.py -m vanth.server`, `bash -lc 'python -m
vanth.server'`, and CLI invocations like `vanth logs --follow`, so
`vanth doctor --reap-orphans` can no longer terminate unrelated processes.
Windows enumeration now uses `Get-CimInstance` with the command line (the old
WMIC CSV parse misread the alphabetically-ordered columns and could not
establish identity). Orphan findings are an advisory warning and no longer
flip `vanth doctor`'s exit code.

Full suite: 783 passed, 6 skipped on Windows; 787 passed, 2 skipped on Linux
(Python 3.12); `go test ./...` green.

## 1.9.0 - 2026-09-10

### Operational hardening (from the 2026-09 roadmap research)
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "vanth"
version = "1.9.0"
version = "1.9.1"
description = "Event-driven background jobs for agents"
readme = "README.md"
requires-python = ">=3.11"
Expand Down Expand Up @@ -72,3 +72,6 @@ path = "build-hooks/bundle_monitor.py"

[tool.pytest.ini_options]
testpaths = ["tests"]
# Makes the tests/shellcmd.py helper importable from every test module,
# including the tests/artifacts and tests/remote subdirectories.
pythonpath = ["tests"]
6 changes: 5 additions & 1 deletion scripts/demo_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import http.client
import json
import os
import shlex
import socket
import subprocess
import sys
Expand All @@ -24,7 +25,10 @@


def cmd(code: str) -> str:
return subprocess.list2cmdline([sys.executable, "-c", code])
argv = [sys.executable, "-c", code]
if sys.platform == "win32":
return subprocess.list2cmdline(argv)
return shlex.join(argv)


def request(method, path, body=None, token=None):
Expand Down
6 changes: 5 additions & 1 deletion scripts/make_monitor_fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import asyncio
import json
import os
import shlex
import subprocess
import sys
import tempfile
Expand All @@ -11,7 +12,10 @@


def cmd(code: str) -> str:
return subprocess.list2cmdline([sys.executable, "-c", code])
argv = [sys.executable, "-c", code]
if sys.platform == "win32":
return subprocess.list2cmdline(argv)
return shlex.join(argv)


def main():
Expand Down
46 changes: 37 additions & 9 deletions src/vanth/artifacts/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -1162,21 +1162,19 @@ def _materialize_dir(

staging_name = f".{dest.name}.materializing-{uuid.uuid4().hex}"
parent_fd = None
path_anchored = False
if os.name == "nt":
staging = dest.parent / staging_name
staging.mkdir()
else:
parent_fd = self._open_parent_fd(dest.parent)
os.mkdir(staging_name, dir_fd=parent_fd)
# Keep construction DESCRIPTOR-RELATIVE wherever the OS exposes a
# descriptor path: /proc/self/fd on Linux, /dev/fd on macOS
# (rc17 review F7). Otherwise fall back to the plain path with a
# dev/inode cross-check of the opened parent.
proc_root = None
if sys.platform.startswith("linux"):
proc_root = "/proc/self/fd"
elif sys.platform == "darwin":
proc_root = "/dev/fd"
# Keep construction DESCRIPTOR-RELATIVE where the OS exposes a
# descriptor path: /proc/self/fd on Linux (rc17 review F7). On macOS
# /dev/fd is not reliable for creating nested entries under a
# directory fd, so use the plain path with a dev/inode cross-check of
# the opened parent instead.
proc_root = "/proc/self/fd" if sys.platform.startswith("linux") else None
fd_dir = Path(proc_root, str(parent_fd)) if proc_root else None
if fd_dir is not None and fd_dir.exists():
staging = fd_dir / staging_name
Expand All @@ -1190,9 +1188,39 @@ def _materialize_dir(
f"destination parent changed during materialization; refusing: {dest.parent}"
)
staging = dest.parent / staging_name
path_anchored = True
try:
try:
self._build_tree_into_staging(staging, entries, heartbeat)
if path_anchored and parent_fd is not None:
# macOS fallback builds through the plain path (no /proc fd
# symlink). Re-verify the destination parent AND the staging
# directory still resolve to the descriptors we opened, so a
# PERSISTENT ancestor swap cannot publish a redirected or
# empty tree — fail closed instead.
# ponytail: residual race remains — a swap restored before
# this check, or a staging-name replacement under an
# unchanged parent, is not caught; closing that needs
# descriptor-relative tree construction on macOS.
try:
path_parent = os.stat(dest.parent)
fd_parent = os.fstat(parent_fd)
path_staging = os.stat(staging)
fd_staging = os.stat(staging_name, dir_fd=parent_fd)
except OSError as exc:
raise ValueError(
f"staging path changed during materialization; refusing: {dest} ({exc})"
) from None
if (path_parent.st_dev, path_parent.st_ino) != (
fd_parent.st_dev,
fd_parent.st_ino,
) or (path_staging.st_dev, path_staging.st_ino) != (
fd_staging.st_dev,
fd_staging.st_ino,
):
raise ValueError(
f"destination parent changed during materialization; refusing: {dest}"
)
# Atomic swap into place: rename fails rather than merges if
# a destination raced into existence.
if parent_fd is None:
Expand Down
22 changes: 20 additions & 2 deletions src/vanth/codex_pipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import json
import os
import queue
import socket
import struct
import subprocess
import sys
Expand Down Expand Up @@ -136,6 +137,11 @@ def _write_all(handle, data: bytes) -> None:
else:
written = handle.write(view)
except OSError as exc:
if isinstance(exc, (BrokenPipeError, ConnectionResetError)):
# A peer that has gone away surfaces as EPIPE/ECONNRESET on
# write on POSIX and ECONNRESET on Windows; report it the same
# way as a read-side close so callers see one consistent error.
raise CodexPipeError("Codex Desktop app-tools host closed the connection") from exc
raise CodexPipeError("lost connection to Codex Desktop app-tools host (write)") from exc
if written is None or written <= 0:
raise CodexPipeError("Codex Desktop app-tools host closed the connection (write)")
Expand Down Expand Up @@ -178,9 +184,21 @@ def __init__(self, pipe_path: str, timeout_seconds: float = DEFAULT_TIMEOUT_SECO
self._request_id = 0

def close(self) -> None:
handle = getattr(self, "handle", None)
if handle is None:
return
# Shutting down a socket before closing it reliably wakes a recv()
# blocked on another thread on POSIX; closing alone does not. Named
# pipe/file handles have no shutdown() and are unaffected.
shutdown = getattr(handle, "shutdown", None)
if shutdown is not None:
try:
shutdown(socket.SHUT_RDWR)
except OSError:
pass
try:
if hasattr(self.handle, "close"):
self.handle.close()
if hasattr(handle, "close"):
handle.close()
except Exception:
pass

Expand Down
11 changes: 11 additions & 0 deletions src/vanth/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import os
import secrets
import signal
import socketserver
import threading
import time
import urllib.parse
Expand Down Expand Up @@ -314,6 +315,16 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
self._active_condition = threading.Condition()
self._active_requests = 0

def server_bind(self) -> None:
# Skip HTTPServer.server_bind's ``socket.getfqdn(host)``: that reverse
# DNS lookup can block for seconds (or hang) on locked-down networks,
# delaying daemon startup past every client timeout. ``server_name`` is
# only cosmetic metadata we do not depend on.
socketserver.TCPServer.server_bind(self)
host, port = self.server_address[:2]
self.server_name = host
self.server_port = port

def _request_finished(self) -> None:
with self._active_condition:
self._active_requests -= 1
Expand Down
24 changes: 19 additions & 5 deletions src/vanth/process_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,11 @@ def _watch_loop(
alive = alive or (lambda: process_alive(parent))
dead_since: float | None = None
idle_since: float | None = None
# Most recent activity from EITHER source, retained across iterations: a
# one-shot stdin traffic sample must be remembered, not just for the single
# iteration that observed it (the tracker's own timestamp can already be
# stale by the next sample, which would reap ~two intervals after traffic).
last_seen_activity = tracker.last_activity()
while True:
time.sleep(interval)
now = time.monotonic()
Expand All @@ -277,13 +282,22 @@ def _watch_loop(
# Recent relay/tool activity resets the idle timer even though the
# process is momentarily not busy (review rc38 P1): the Desktop wake
# relay polls and delivers asynchronously without holding an MCP
# request context, and a healthy relay must not be idle-reaped.
if now - tracker.last_activity() < interval:
idle_since = None
elif traffic():
# request context, and a healthy relay must not be idle-reaped. The
# freshness window is the idle threshold itself — using the (tiny)
# sampling interval as the window mis-reaped a healthy relay whose
# notify cadence was coarser than the sampler (macOS runners).
# ``idle_since`` is seeded from the activity timestamp itself, so
# the effective timeout is ``idle`` (not ~2x from starting a second
# window once the freshness window expires).
if traffic():
last_seen_activity = now
tracker_activity = tracker.last_activity()
if tracker_activity > last_seen_activity:
last_seen_activity = tracker_activity
if now - last_seen_activity < idle:
idle_since = None
elif idle_since is None:
idle_since = now
idle_since = last_seen_activity
elif now - idle_since >= idle:
on_exit()
return
Expand Down
Loading
Loading