From 69bb0515be60b20b607a7bc6ef6aab50839d60a6 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sun, 30 Aug 2026 20:00:18 +0900 Subject: [PATCH 1/8] Support PASEO port ranges in dev allocation --- ENVIRONMENT.md | 9 +++- docs/configuration.md | 9 +++- scripts/worktree-env.sh | 91 ++++++++++++++++++++++++--------- tests/unit/test_worktree_env.py | 76 +++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 27 deletions(-) diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 6500b65c..1bc23057 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -2,8 +2,13 @@ Use `.env.example` as the source of defaults. -When running inside Conductor, `CONDUCTOR_PORT` is treated as the first port in -the workspace's 10-port range for unset worktree defaults: Redis uses `+0`, +When running inside PASEO, `PASEO_PORT_BASE` and `PASEO_PORT_END` reserve the +inclusive range used for unset worktree defaults. The range must contain at +least seven ports: Redis uses `+0`, Postgres `+1`, the Compose web port `+2`, +MinIO API `+3`, MinIO Console `+4`, host-run web/API `+5`, and the bot health +check `+6`. PASEO takes precedence over Conductor. When running inside +Conductor, `CONDUCTOR_PORT` is treated as the first port in the workspace's +10-port range for unset worktree defaults: Redis uses `+0`, Postgres `+1`, Compose web `+2`, MinIO API `+3`, MinIO console `+4`, host-run web/API `+5`, and bot health `+6`. Explicit service port overrides keep their current precedence rules. diff --git a/docs/configuration.md b/docs/configuration.md index 0ddbd7d1..af36dc2c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -112,8 +112,13 @@ For local dev, `./scripts/dev.sh` and `./scripts/docker-compose.sh` compute deterministic per-worktree ports unless values are pinned in `.env` or the invoking shell. -Inside Conductor, `CONDUCTOR_PORT` is treated as the first port in the -workspace's 10-port range for unset worktree defaults: Redis uses `+0`, +Inside PASEO, `PASEO_PORT_BASE` and `PASEO_PORT_END` reserve the inclusive +range used for unset worktree defaults. The range must contain at least seven +ports: Redis uses `+0`, Postgres `+1`, the Compose web port `+2`, MinIO API +`+3`, MinIO Console `+4`, host-run web/API `+5`, and the bot health check `+6`. +PASEO takes precedence over Conductor. Inside Conductor, `CONDUCTOR_PORT` is +treated as the first port in the workspace's 10-port range for unset worktree +defaults: Redis uses `+0`, Postgres `+1`, Compose web `+2`, MinIO API `+3`, MinIO console `+4`, host-run web/API `+5`, and bot health `+6`. Explicit service port overrides keep their current precedence rules. diff --git a/scripts/worktree-env.sh b/scripts/worktree-env.sh index 0d97924e..b562d9f0 100755 --- a/scripts/worktree-env.sh +++ b/scripts/worktree-env.sh @@ -109,6 +109,32 @@ worktree_env_resolve_conductor_port_base() { printf '%s' "$conductor_port_base" } +worktree_env_resolve_paseo_port_base() { + paseo_port_base=${PASEO_PORT_BASE-} + paseo_port_end=${PASEO_PORT_END-} + + if [ -z "$paseo_port_base" ] && [ -z "$paseo_port_end" ]; then + return 1 + fi + + if [ -z "$paseo_port_base" ] || [ -z "$paseo_port_end" ]; then + echo "PASEO_PORT_BASE and PASEO_PORT_END must be set together." >&2 + return 2 + fi + + worktree_env_validate_port_number "$paseo_port_base" "PASEO_PORT_BASE" || return 2 + worktree_env_validate_port_number "$paseo_port_end" "PASEO_PORT_END" || return 2 + paseo_port_base=$(worktree_env_decimal_value "$paseo_port_base") + paseo_port_end=$(worktree_env_decimal_value "$paseo_port_end") + + if [ "$paseo_port_end" -lt $((paseo_port_base + 6)) ]; then + echo "PASEO_PORT_BASE through PASEO_PORT_END must include at least seven ports, got '${PASEO_PORT_BASE}' through '${PASEO_PORT_END}'." >&2 + return 2 + fi + + printf '%s' "$paseo_port_base" +} + worktree_env_validate_port_number() { port_value=$1 port_label=$2 @@ -154,8 +180,12 @@ worktree_env_finalize_browser_safe_port() { resolved_value=$(worktree_env_decimal_value "$resolved_value") if [ "$source_label" = "default" ]; then - if [ "${WORKTREE_ENV_PORT_DEFAULT_SOURCE-}" = "conductor" ] && worktree_env_is_browser_unsafe_port "$resolved_value"; then - echo "$port_label defaults to browser-unsafe port '$resolved_value' from CONDUCTOR_PORT; set $port_label explicitly or use a different CONDUCTOR_PORT." \ + if { [ "${WORKTREE_ENV_PORT_DEFAULT_SOURCE-}" = "conductor" ] || [ "${WORKTREE_ENV_PORT_DEFAULT_SOURCE-}" = "paseo" ]; } && worktree_env_is_browser_unsafe_port "$resolved_value"; then + source_name=CONDUCTOR_PORT + if [ "${WORKTREE_ENV_PORT_DEFAULT_SOURCE-}" = "paseo" ]; then + source_name=PASEO_PORT_BASE + fi + echo "$port_label defaults to browser-unsafe port '$resolved_value' from $source_name; set $port_label explicitly or use a different $source_name." \ >&2 return 1 fi @@ -285,29 +315,44 @@ worktree_env_load() { fi COMPOSE_PROJECT_NAME=$(worktree_env_resolve_value COMPOSE_PROJECT_NAME "${project_name}-$(printf '%04d' "$WORKTREE_ENV_SLOT")" "$WORKTREE_ENV_FILE") - if conductor_port_base=$(worktree_env_resolve_conductor_port_base); then - WORKTREE_ENV_PORT_DEFAULT_SOURCE=conductor - REDIS_HOST_PORT_DEFAULT=$conductor_port_base - POSTGRES_HOST_PORT_DEFAULT=$((conductor_port_base + 1)) - WEB_HOST_PORT_DEFAULT=$((conductor_port_base + 2)) - MINIO_API_HOST_PORT_DEFAULT=$((conductor_port_base + 3)) - MINIO_CONSOLE_HOST_PORT_DEFAULT=$((conductor_port_base + 4)) - WEB_PORT_DEFAULT=$((conductor_port_base + 5)) - HEALTHCHECK_PORT_DEFAULT=$((conductor_port_base + 6)) + if paseo_port_base=$(worktree_env_resolve_paseo_port_base); then + WORKTREE_ENV_PORT_DEFAULT_SOURCE=paseo + REDIS_HOST_PORT_DEFAULT=$paseo_port_base + POSTGRES_HOST_PORT_DEFAULT=$((paseo_port_base + 1)) + WEB_HOST_PORT_DEFAULT=$((paseo_port_base + 2)) + MINIO_API_HOST_PORT_DEFAULT=$((paseo_port_base + 3)) + MINIO_CONSOLE_HOST_PORT_DEFAULT=$((paseo_port_base + 4)) + WEB_PORT_DEFAULT=$((paseo_port_base + 5)) + HEALTHCHECK_PORT_DEFAULT=$((paseo_port_base + 6)) else - conductor_port_status=$? - if [ "$conductor_port_status" -ne 1 ]; then - return "$conductor_port_status" + paseo_port_status=$? + if [ "$paseo_port_status" -ne 1 ]; then + return "$paseo_port_status" + fi + if conductor_port_base=$(worktree_env_resolve_conductor_port_base); then + WORKTREE_ENV_PORT_DEFAULT_SOURCE=conductor + REDIS_HOST_PORT_DEFAULT=$conductor_port_base + POSTGRES_HOST_PORT_DEFAULT=$((conductor_port_base + 1)) + WEB_HOST_PORT_DEFAULT=$((conductor_port_base + 2)) + MINIO_API_HOST_PORT_DEFAULT=$((conductor_port_base + 3)) + MINIO_CONSOLE_HOST_PORT_DEFAULT=$((conductor_port_base + 4)) + WEB_PORT_DEFAULT=$((conductor_port_base + 5)) + HEALTHCHECK_PORT_DEFAULT=$((conductor_port_base + 6)) + else + conductor_port_status=$? + if [ "$conductor_port_status" -ne 1 ]; then + return "$conductor_port_status" + fi + + WORKTREE_ENV_PORT_DEFAULT_SOURCE=worktree + REDIS_HOST_PORT_DEFAULT=$((12000 + WORKTREE_ENV_SLOT)) + POSTGRES_HOST_PORT_DEFAULT=$((15432 + WORKTREE_ENV_SLOT)) + WEB_HOST_PORT_DEFAULT=$((20080 + WORKTREE_ENV_SLOT)) + MINIO_API_HOST_PORT_DEFAULT=$((24000 + WORKTREE_ENV_SLOT)) + MINIO_CONSOLE_HOST_PORT_DEFAULT=$((28000 + WORKTREE_ENV_SLOT)) + WEB_PORT_DEFAULT=$((18080 + WORKTREE_ENV_SLOT)) + HEALTHCHECK_PORT_DEFAULT=$((30000 + WORKTREE_ENV_SLOT)) fi - - WORKTREE_ENV_PORT_DEFAULT_SOURCE=worktree - REDIS_HOST_PORT_DEFAULT=$((12000 + WORKTREE_ENV_SLOT)) - POSTGRES_HOST_PORT_DEFAULT=$((15432 + WORKTREE_ENV_SLOT)) - WEB_HOST_PORT_DEFAULT=$((20080 + WORKTREE_ENV_SLOT)) - MINIO_API_HOST_PORT_DEFAULT=$((24000 + WORKTREE_ENV_SLOT)) - MINIO_CONSOLE_HOST_PORT_DEFAULT=$((28000 + WORKTREE_ENV_SLOT)) - WEB_PORT_DEFAULT=$((18080 + WORKTREE_ENV_SLOT)) - HEALTHCHECK_PORT_DEFAULT=$((30000 + WORKTREE_ENV_SLOT)) fi REDIS_HOST_PORT=$(worktree_env_resolve_value REDIS_HOST_PORT "$REDIS_HOST_PORT_DEFAULT" "$WORKTREE_ENV_FILE") diff --git a/tests/unit/test_worktree_env.py b/tests/unit/test_worktree_env.py index d9d6ed8a..8b152cd7 100644 --- a/tests/unit/test_worktree_env.py +++ b/tests/unit/test_worktree_env.py @@ -13,6 +13,8 @@ def _base_env() -> dict[str, str]: env = os.environ.copy() env.pop("CONDUCTOR_PORT", None) + env.pop("PASEO_PORT_BASE", None) + env.pop("PASEO_PORT_END", None) env.pop("WORKTREE_ENV_PORT_DEFAULT_SOURCE", None) for key in ( "REDIS_HOST_PORT", @@ -239,6 +241,80 @@ def test_worktree_env_load_uses_conductor_port_range_for_defaults() -> None: ] +def test_worktree_env_load_uses_paseo_port_range_for_defaults() -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + repo_root = Path(tmp_dir) + scripts_dir = repo_root / "scripts" + scripts_dir.mkdir() + env = _base_env() + env["CONDUCTOR_PORT"] = "45000" + env["PASEO_PORT_BASE"] = "46000" + env["PASEO_PORT_END"] = "46006" + + result = _run_shell( + f""" + set -eu + . {SCRIPT_PATH} + worktree_env_load {scripts_dir} host + printf '%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n' \\ + "$REDIS_HOST_PORT" \\ + "$POSTGRES_HOST_PORT" \\ + "$WEB_HOST_PORT" \\ + "$MINIO_API_HOST_PORT" \\ + "$MINIO_CONSOLE_HOST_PORT" \\ + "$WEB_PORT" \\ + "$HEALTHCHECK_PORT" + """, + env=env, + ) + + assert result.returncode == 0 + assert result.stdout.splitlines() == [ + "46000", + "46001", + "46002", + "46003", + "46004", + "46005", + "46006", + ] + + +def test_worktree_env_load_rejects_incomplete_paseo_port_range() -> None: + env = _base_env() + env["PASEO_PORT_BASE"] = "46000" + + result = _run_shell( + f""" + set -eu + . {SCRIPT_PATH} + worktree_env_load {REPO_ROOT / "scripts"} host + """, + env=env, + ) + + assert result.returncode != 0 + assert "PASEO_PORT_BASE and PASEO_PORT_END must be set together." in result.stderr + + +def test_worktree_env_load_rejects_too_small_paseo_port_range() -> None: + env = _base_env() + env["PASEO_PORT_BASE"] = "46000" + env["PASEO_PORT_END"] = "46005" + + result = _run_shell( + f""" + set -eu + . {SCRIPT_PATH} + worktree_env_load {REPO_ROOT / "scripts"} host + """, + env=env, + ) + + assert result.returncode != 0 + assert "PASEO_PORT_BASE through PASEO_PORT_END must include at least seven ports" in result.stderr + + def test_worktree_env_print_port_summary_includes_host_service_ports() -> None: with tempfile.TemporaryDirectory() as tmp_dir: repo_root = Path(tmp_dir) From b74878bc67289a8574f9d69c56962ea93aef1dce Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sun, 30 Aug 2026 20:03:06 +0900 Subject: [PATCH 2/8] Add PASEO development launch config --- paseo.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 paseo.json diff --git a/paseo.json b/paseo.json new file mode 100644 index 00000000..5eb4e2f8 --- /dev/null +++ b/paseo.json @@ -0,0 +1,8 @@ +{ + "scripts": { + "dev": { + "type": "service", + "command": "./scripts/dev.sh all" + } + } +} From 4ec0401d1547ffd5cc884b762a93e571eec0abdd Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sun, 30 Aug 2026 20:19:56 +0900 Subject: [PATCH 3/8] Format PASEO port range tests --- tests/unit/test_worktree_env.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_worktree_env.py b/tests/unit/test_worktree_env.py index 8b152cd7..31ecd973 100644 --- a/tests/unit/test_worktree_env.py +++ b/tests/unit/test_worktree_env.py @@ -312,7 +312,10 @@ def test_worktree_env_load_rejects_too_small_paseo_port_range() -> None: ) assert result.returncode != 0 - assert "PASEO_PORT_BASE through PASEO_PORT_END must include at least seven ports" in result.stderr + assert ( + "PASEO_PORT_BASE through PASEO_PORT_END must include at least seven ports" + in result.stderr + ) def test_worktree_env_print_port_summary_includes_host_service_ports() -> None: From 3e7ff1a0fe03c32d00ce8cebf75fdab7ad36929b Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Mon, 31 Aug 2026 12:47:36 +0900 Subject: [PATCH 4/8] Use PASEO ports when archiving workspaces --- scripts/archive-workspace.sh | 42 ++++++++++++++++++++-------- tests/unit/test_archive_workspace.py | 29 +++++++++++++++++++ 2 files changed, 59 insertions(+), 12 deletions(-) create mode 100644 tests/unit/test_archive_workspace.py diff --git a/scripts/archive-workspace.sh b/scripts/archive-workspace.sh index dc2de112..0a077745 100755 --- a/scripts/archive-workspace.sh +++ b/scripts/archive-workspace.sh @@ -237,17 +237,28 @@ def listening_pids_for_port(port: int) -> list[int]: return [int(line) for line in result.stdout.splitlines() if line.strip().isdigit()] -def conductor_ports() -> list[int]: - value = os.environ.get("CONDUCTOR_PORT") - if not value: - return [] +def allocated_port_range() -> tuple[str, list[int]]: + paseo_base = os.environ.get("PASEO_PORT_BASE") + paseo_end = os.environ.get("PASEO_PORT_END") + if paseo_base and paseo_end: + try: + base = int(paseo_base) + end = int(paseo_end) + except ValueError: + return "", [] + if 1 <= base <= end <= 65535 and end >= base + 6: + return "PASEO_PORT range", list(range(base, end + 1)) + + conductor_port = os.environ.get("CONDUCTOR_PORT") + if not conductor_port: + return "", [] try: - base = int(value) + base = int(conductor_port) except ValueError: - return [] + return "", [] if base < 1 or base > 65526: - return [] - return [base + offset for offset in range(10)] + return "", [] + return "CONDUCTOR_PORT range", [base + offset for offset in range(10)] processes = list_processes() @@ -281,7 +292,8 @@ for pid in list(selected): "descendant of workspace dev process", ) -for port in conductor_ports(): +port_range_name, allocated_ports = allocated_port_range() +for port in allocated_ports: for pid in listening_pids_for_port(port): process = processes.get(pid) if process is None or not is_alive(pid): @@ -291,13 +303,13 @@ for port in conductor_ports(): if command_mentions_workspace(process.command) or is_under_workspace( cached_cwd(pid) ): - selected.setdefault(pid, f"listening on CONDUCTOR_PORT range port {port}") + selected.setdefault(pid, f"listening on {port_range_name} port {port}") add_descendants( pid, processes, children, selected, - f"descendant of listener on CONDUCTOR_PORT range port {port}", + f"descendant of listener on {port_range_name} port {port}", ) for pid in sorted(selected): @@ -335,7 +347,13 @@ signal_processes() { } echo "Archiving workspace: $workspace" -if [[ "${CONDUCTOR_PORT:-}" =~ ^[0-9]+$ ]]; then +if [[ "${PASEO_PORT_BASE:-}" =~ ^[0-9]+$ && "${PASEO_PORT_END:-}" =~ ^[0-9]+$ ]]; then + paseo_port_base=$(normalize_decimal "$PASEO_PORT_BASE") + paseo_port_end=$(normalize_decimal "$PASEO_PORT_END") + if [ "$paseo_port_base" -ge 1 ] && [ "$paseo_port_end" -le 65535 ] && [ "$paseo_port_end" -ge $((paseo_port_base + 6)) ]; then + echo "PASEO port range: ${paseo_port_base}..${paseo_port_end}" + fi +elif [[ "${CONDUCTOR_PORT:-}" =~ ^[0-9]+$ ]]; then conductor_port_base=$(normalize_decimal "$CONDUCTOR_PORT") echo "Conductor port range: ${conductor_port_base}..$((conductor_port_base + 9))" fi diff --git a/tests/unit/test_archive_workspace.py b/tests/unit/test_archive_workspace.py new file mode 100644 index 00000000..737655d8 --- /dev/null +++ b/tests/unit/test_archive_workspace.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPT_PATH = REPO_ROOT / "scripts" / "archive-workspace.sh" + + +def test_archive_workspace_prefers_paseo_port_range() -> None: + env = os.environ.copy() + env["CONDUCTOR_PORT"] = "45000" + env["PASEO_PORT_BASE"] = "46000" + env["PASEO_PORT_END"] = "46006" + + result = subprocess.run( + [str(SCRIPT_PATH), "--dry-run", "--skip-docker"], + cwd=REPO_ROOT, + env=env, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert "PASEO port range: 46000..46006" in result.stdout + assert "Conductor port range" not in result.stdout From dd1fffa97b6ad0c293b978e1ea15ee4689b85ca0 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Mon, 31 Aug 2026 15:40:31 +0900 Subject: [PATCH 5/8] Print PASEO port range for debugging --- scripts/dev.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/dev.sh b/scripts/dev.sh index 4313bd7e..060b88a4 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -5,6 +5,9 @@ script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) . "$script_dir/worktree-env.sh" worktree_env_load "$script_dir" +printf 'PASEO_PORT_BASE=%s\n' "${PASEO_PORT_BASE-}" +printf 'PASEO_PORT_END=%s\n' "${PASEO_PORT_END-}" + UV_BIN=${UV_BIN:-$(command -v uv)} export UV_BIN From caa046376e11a34334939651da87cb163bfc48b1 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Mon, 31 Aug 2026 15:42:20 +0900 Subject: [PATCH 6/8] Remove PASEO port debug output --- scripts/dev.sh | 3 --- 1 file changed, 3 deletions(-) diff --git a/scripts/dev.sh b/scripts/dev.sh index 060b88a4..4313bd7e 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -5,9 +5,6 @@ script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) . "$script_dir/worktree-env.sh" worktree_env_load "$script_dir" -printf 'PASEO_PORT_BASE=%s\n' "${PASEO_PORT_BASE-}" -printf 'PASEO_PORT_END=%s\n' "${PASEO_PORT_END-}" - UV_BIN=${UV_BIN:-$(command -v uv)} export UV_BIN From d7279fcba85cd73d83a1e607f20fbef69d9d35de Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 1 Sep 2026 05:16:03 +0900 Subject: [PATCH 7/8] Harden PASEO workspace archival discovery --- scripts/archive-workspace.sh | 16 ++-- tests/unit/test_archive_workspace.py | 121 ++++++++++++++++++++++++--- 2 files changed, 117 insertions(+), 20 deletions(-) diff --git a/scripts/archive-workspace.sh b/scripts/archive-workspace.sh index 0a077745..927e7fd9 100755 --- a/scripts/archive-workspace.sh +++ b/scripts/archive-workspace.sh @@ -240,14 +240,16 @@ def listening_pids_for_port(port: int) -> list[int]: def allocated_port_range() -> tuple[str, list[int]]: paseo_base = os.environ.get("PASEO_PORT_BASE") paseo_end = os.environ.get("PASEO_PORT_END") - if paseo_base and paseo_end: - try: - base = int(paseo_base) - end = int(paseo_end) - except ValueError: - return "", [] + if ( + paseo_base + and paseo_end + and re.fullmatch(r"[0-9]+", paseo_base) + and re.fullmatch(r"[0-9]+", paseo_end) + ): + base = int(paseo_base) + end = int(paseo_end) if 1 <= base <= end <= 65535 and end >= base + 6: - return "PASEO_PORT range", list(range(base, end + 1)) + return "PASEO_PORT range", [base + offset for offset in range(7)] conductor_port = os.environ.get("CONDUCTOR_PORT") if not conductor_port: diff --git a/tests/unit/test_archive_workspace.py b/tests/unit/test_archive_workspace.py index 737655d8..54bbe1bb 100644 --- a/tests/unit/test_archive_workspace.py +++ b/tests/unit/test_archive_workspace.py @@ -2,6 +2,7 @@ import os import subprocess +import sys from pathlib import Path @@ -9,21 +10,115 @@ SCRIPT_PATH = REPO_ROOT / "scripts" / "archive-workspace.sh" -def test_archive_workspace_prefers_paseo_port_range() -> None: - env = os.environ.copy() - env["CONDUCTOR_PORT"] = "45000" - env["PASEO_PORT_BASE"] = "46000" - env["PASEO_PORT_END"] = "46006" +def _write_fake_lsof(tmp_path: Path) -> Path: + fake_lsof = tmp_path / "lsof" + fake_lsof.write_text( + """#!/bin/sh +listener_pid=$(ps -axo pid=,command= | awk -v marker="$TEST_LISTENER_MARKER" 'index($0, marker) { print $1; exit }') +if [ \"${1:-}\" = \"-a\" ]; then + if [ \"${3:-}\" = \"$listener_pid\" ]; then + printf 'n%s\\n' \"$TEST_WORKSPACE\" + fi + exit 0 +fi +for argument in \"$@\"; do + case \"$argument\" in + -tiTCP:*) + printf '%s\\n' \"${argument#-tiTCP:}\" >>\"$TEST_LSOF_PORT_LOG\" + if [ \"${argument#-tiTCP:}\" = \"$TEST_LISTENER_PORT\" ]; then + printf '%s\\n' \"$listener_pid\" + fi + ;; + esac +done +""", + encoding="utf-8", + ) + fake_lsof.chmod(0o755) + return fake_lsof - result = subprocess.run( - [str(SCRIPT_PATH), "--dry-run", "--skip-docker"], + +def _run_archive_with_listener( + tmp_path: Path, + *, + conductor_port: str, + paseo_port_base: str, + paseo_port_end: str, + listener_port: str, +) -> tuple[subprocess.CompletedProcess[str], str, list[str]]: + env = os.environ.copy() + listener_marker = "paseo-archive-test-listener" + listener = subprocess.Popen( + [ + sys.executable, + "-c", + "import time; time.sleep(30)", + listener_marker, + ], cwd=REPO_ROOT, - env=env, - check=False, - capture_output=True, - text=True, + ) + port_log = tmp_path / "lsof-ports.log" + _write_fake_lsof(tmp_path) + env.update( + { + "CONDUCTOR_PORT": conductor_port, + "PASEO_PORT_BASE": paseo_port_base, + "PASEO_PORT_END": paseo_port_end, + "PATH": f"{tmp_path}:{env['PATH']}", + "TEST_WORKSPACE": str(REPO_ROOT), + "TEST_LISTENER_MARKER": listener_marker, + "TEST_LISTENER_PORT": listener_port, + "TEST_LSOF_PORT_LOG": str(port_log), + } + ) + + try: + result = subprocess.run( + [str(SCRIPT_PATH), "--dry-run", "--skip-docker"], + cwd=REPO_ROOT, + env=env, + check=False, + capture_output=True, + text=True, + ) + finally: + listener.terminate() + listener.wait(timeout=5) + + return result, listener_marker, port_log.read_text(encoding="utf-8").splitlines() + + +def test_archive_workspace_discovers_paseo_only_listener_on_assigned_port( + tmp_path: Path, +) -> None: + result, listener_marker, scanned_ports = _run_archive_with_listener( + tmp_path, + conductor_port="45000", + paseo_port_base="46000", + paseo_port_end="65000", + listener_port="46006", + ) + + assert result.returncode == 0, result.stderr + assert "reason=listening on PASEO_PORT range port 46006" in result.stdout + assert listener_marker in result.stdout + assert scanned_ports == [str(port) for port in range(46000, 46007)] + + +def test_archive_workspace_falls_back_to_conductor_for_malformed_paseo_ports( + tmp_path: Path, +) -> None: + result, listener_marker, scanned_ports = _run_archive_with_listener( + tmp_path, + conductor_port="45000", + paseo_port_base="+46000", + paseo_port_end="46006", + listener_port="45000", ) assert result.returncode == 0, result.stderr - assert "PASEO port range: 46000..46006" in result.stdout - assert "Conductor port range" not in result.stdout + assert "reason=listening on CONDUCTOR_PORT range port 45000" in result.stdout + assert listener_marker in result.stdout + assert scanned_ports == [str(port) for port in range(45000, 45010)] + assert "PASEO port range" not in result.stdout + assert "Conductor port range: 45000..45009" in result.stdout From 0d28b29c6e00015a65c35035b07c020bdb3a8fad Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 1 Sep 2026 23:08:27 +0900 Subject: [PATCH 8/8] Allow archive teardown with invalid PASEO ports --- scripts/archive-workspace.sh | 14 +++++++++ tests/unit/test_archive_workspace.py | 45 ++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/scripts/archive-workspace.sh b/scripts/archive-workspace.sh index 927e7fd9..f51016af 100755 --- a/scripts/archive-workspace.sh +++ b/scripts/archive-workspace.sh @@ -26,6 +26,17 @@ normalize_decimal() { printf '%s' "$value" } +paseo_port_range_is_valid() { + local base end + base=${PASEO_PORT_BASE:-} + end=${PASEO_PORT_END:-} + [[ "$base" =~ ^[0-9]+$ && "$end" =~ ^[0-9]+$ ]] || return 1 + + base=$(normalize_decimal "$base") + end=$(normalize_decimal "$end") + [ "$base" -ge 1 ] && [ "$end" -le 65535 ] && [ "$end" -ge $((base + 6)) ] +} + while [ "$#" -gt 0 ]; do case "$1" in -n|--dry-run) @@ -376,5 +387,8 @@ if [ "$skip_docker" -eq 1 ]; then elif [ "$dry_run" -eq 1 ]; then echo "[dry-run] ./scripts/docker-compose.sh down --remove-orphans" else + if ! paseo_port_range_is_valid; then + unset PASEO_PORT_BASE PASEO_PORT_END + fi ./scripts/docker-compose.sh down --remove-orphans fi diff --git a/tests/unit/test_archive_workspace.py b/tests/unit/test_archive_workspace.py index 54bbe1bb..d8fed2ed 100644 --- a/tests/unit/test_archive_workspace.py +++ b/tests/unit/test_archive_workspace.py @@ -122,3 +122,48 @@ def test_archive_workspace_falls_back_to_conductor_for_malformed_paseo_ports( assert scanned_ports == [str(port) for port in range(45000, 45010)] assert "PASEO port range" not in result.stdout assert "Conductor port range: 45000..45009" in result.stdout + + +def test_archive_workspace_unsets_malformed_paseo_ports_before_compose_teardown( + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + scripts_dir = workspace / "scripts" + scripts_dir.mkdir(parents=True) + compose_env = tmp_path / "compose-env.txt" + compose_script = scripts_dir / "docker-compose.sh" + compose_script.write_text( + """#!/bin/sh +printf 'PASEO_PORT_BASE=%s\\nPASEO_PORT_END=%s\\nCONDUCTOR_PORT=%s\\nARGS=%s\\n' \\ + \"${PASEO_PORT_BASE-}\" \"${PASEO_PORT_END-}\" \"${CONDUCTOR_PORT-}\" \"$*\" > \"$TEST_COMPOSE_ENV\" +""", + encoding="utf-8", + ) + compose_script.chmod(0o755) + + env = os.environ.copy() + env.update( + { + "CONDUCTOR_WORKSPACE_PATH": str(workspace), + "CONDUCTOR_PORT": "45000", + "PASEO_PORT_BASE": "+46000", + "PASEO_PORT_END": "46006", + "TEST_COMPOSE_ENV": str(compose_env), + } + ) + result = subprocess.run( + [str(SCRIPT_PATH)], + cwd=REPO_ROOT, + env=env, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert compose_env.read_text(encoding="utf-8").splitlines() == [ + "PASEO_PORT_BASE=", + "PASEO_PORT_END=", + "CONDUCTOR_PORT=45000", + "ARGS=down --remove-orphans", + ]