From ff553c080b0882e08f84beea678e45cd578b9bb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 19:00:07 +0000 Subject: [PATCH] session: regenerate the session-start hook; note the two remote-session lines The generated copy of PyAutoMind's canonical `policy/session_start_hook.sh`, regenerated by `repos_sync.py --write` after the multi-repo session fixes (pytest-xdist in the base deps, a venv-backed `python3` wrapper rather than a symlink to the base interpreter, a reachable workspace-root fan-out). AGENTS.md gains two measured lines, because this file is loaded in every session and a remote one has little else to go on: run the suite with `-n auto` (554 tests, 96s on one core and 28s on four), and knock on `session_bootstrap.sh` in the first turn, since a session holding several organs registers no SessionStart hook at all. Validation: 554 tests pass, `repos_sync.py --check` clean against this copy. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XLmJCBKNoh2tB5UkdecsSn --- .claude/hooks/session-start.sh | 177 +++++++++++++++++++++++++++++---- AGENTS.md | 15 +++ 2 files changed, 172 insertions(+), 20 deletions(-) diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index eaec734c..98037e65 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -45,7 +45,10 @@ set -euo pipefail [ "${CLAUDE_CODE_REMOTE:-}" = "true" ] || exit 0 VENV="${PYAUTO_SESSION_VENV:-$HOME/.pyauto/session-py312}" -BASE_DEPS=(pytest PyYAML) +# pytest-xdist is a base dep, not a nicety: a remote container has 4 cores and +# the Brain suite takes 96s on one of them and 28s on four. A session that has +# to `pip install` before it can run tests fast will simply not run them fast. +BASE_DEPS=(pytest PyYAML pytest-xdist) # Which checkout is this copy of the hook installed in? # @@ -100,7 +103,7 @@ find_base_python() { venv_ready() { is_py312 "$VENV/bin/python" \ && [ -x "$VENV/bin/pip" ] \ - && "$VENV/bin/python" -c 'import pytest, yaml' >/dev/null 2>&1 + && "$VENV/bin/python" -c 'import pytest, yaml, xdist' >/dev/null 2>&1 } # 1. The interpreter a session types: python, python3, pip, pytest. @@ -117,13 +120,19 @@ ensure_venv() { log "building $VENV on $base_python" rm -rf "$VENV" mkdir -p "$(dirname "$VENV")" + # --system-site-packages makes the venv a strict SUPERSET of the base + # interpreter. That is what lets `point_system_default` make this venv the + # session's `python3` without silently removing anything the image + # installed: an isolated venv would swap one set of missing modules for + # another, and the session would find out through a ModuleNotFoundError + # that reads like broken code. if command -v uv >/dev/null 2>&1; then # --seed puts pip inside the venv too, so `pip install` targets 3.12 # rather than falling through to the container's 3.11 /usr/bin/pip. - uv venv --seed --python "$base_python" "$VENV" >&2 + uv venv --seed --system-site-packages --python "$base_python" "$VENV" >&2 uv pip install --python "$VENV/bin/python" --quiet "${BASE_DEPS[@]}" >&2 else - "$base_python" -m venv "$VENV" >&2 + "$base_python" -m venv --system-site-packages "$VENV" >&2 "$VENV/bin/python" -m pip install --quiet --upgrade pip >&2 "$VENV/bin/python" -m pip install --quiet "${BASE_DEPS[@]}" >&2 fi @@ -152,16 +161,117 @@ ensure_repo_extras() { } # 2. What PATH means without this session's env file. +# +# This leg is the one that matters most, because the env file is exactly what a +# multi-repo session does NOT get: Claude Code registers project hooks from the +# session's project directory, that directory is the repos' parent, and no hook +# runs there (see leg 5). So every Bash call in such a session resolves whatever +# the image put on PATH, and these two names are the fallback that has to be +# right on its own. +# +# It used to point them at the BASE interpreter — `point_system_default +# "$(readlink -f "$VENV/bin/python")"` resolved the venv's python straight +# through to /usr/bin/python3.12. That satisfied the version question and lost +# everything else: `python3 -m pytest` answered "No module named pytest", and +# the session's own venv, sitting one directory away with pytest in it, was +# unreachable from any shell. +# +# Two details make the fix work: +# +# * A wrapper script, not a symlink. Python resolves a symlinked executable +# before looking for `pyvenv.cfg`, so a symlink to $VENV/bin/python lands on +# the base interpreter's prefix and the venv is lost again — silently, and +# in the same shape as the bug being fixed. `exec`ing it keeps the venv. +# * --system-site-packages on the venv (see ensure_venv), so pointing the +# system default here adds the session's packages without removing the +# image's. +# Bounded, because this predicate is what runs a candidate interpreter — and +# the one failure mode worth surviving is an interpreter that never returns. +venv_backed() { + [ -x "$1" ] || return 1 + [ "$(timeout 20 "$1" -c 'import sys; print(sys.prefix)' 2>/dev/null)" = "$VENV" ] +} + +# Does following $1's symlinks pass through $2? A venv's python legitimately +# RESOLVES to the same base interpreter a system default points at, so comparing +# endpoints refuses safe rewrites. The question that matters is narrower: is the +# path being rewritten a link in the target's own chain — because then the +# wrapper execs itself. +links_through() { + local node="$1" needle="$2" hops=0 + needle="$(cd "$(dirname "$needle")" 2>/dev/null && printf '%s/%s' "$(pwd -P)" "$(basename "$needle")")" + while [ -L "$node" ] && [ "$hops" -lt 40 ]; do + node="$(cd "$(dirname "$node")" && cd "$(dirname "$(readlink "$node")")" 2>/dev/null && printf '%s/%s' "$(pwd -P)" "$(basename "$(readlink "$node")")")" + [ "$node" = "$needle" ] && return 0 + hops=$((hops + 1)) + done + return 1 +} + +# `rm -f` first, and it is not tidiness. /usr/local/bin/python3 is a SYMLINK to +# the real interpreter, and a redirect opens the link's TARGET: `cat >` there +# overwrites /usr/bin/python3.12 itself with this wrapper. The venv's own +# python symlinks to that same file, so the wrapper then execs itself — the +# container loses its interpreter and every `python3` spins at 100% CPU. Writing +# a fresh file at the path replaces the link instead of following it. +# +# `links_through` is the second half of the same guard: a target that reaches +# the destination through its own symlink chain builds the identical loop by the +# other route, so refuse rather than write it. +write_venv_shim() { + local dest="$1" target="$2" + [ -w "$(dirname "$dest")" ] || { log "WARNING: cannot rewrite $dest (not writable)"; return 1; } + [ -x "$target" ] || { log "WARNING: $target is not executable; leaving $dest alone"; return 1; } + if links_through "$target" "$dest"; then + log "WARNING: refusing to point $dest at $target — it links back through $dest" + return 1 + fi + rm -f "$dest" + cat >"$dest" < $VENV/bin/python (venv, with pytest and yaml)" \ + || log "WARNING: $bin/python3 is still $(timeout 20 "$bin/python3" -V 2>&1)" +} + +# 2b. The `pytest` that PATH actually finds. +# +# $HOME/.local/bin precedes /usr/local/bin, and it holds uv's tool shims — so +# fixing python3 alone still leaves bare `pytest` resolving to uv's ISOLATED +# pytest, which by design cannot see PyYAML or a repo's own extras. In this +# workspace that made `pytest` exit on four collection ImportErrors that read +# like broken source, in a session where the suite was in fact green. +# +# A session should have exactly one pytest, and it should be the venv's. +# Confined to uv's own shim directory: a distro-packaged pytest is not ours to +# overwrite. Runs after retool_uv_tools, which rewrites these same shims. +point_pytest_at_venv() { + local shim_dir="${HOME}/.local/bin" name path + [ -x "$VENV/bin/pytest" ] || return 0 + for name in pytest py.test; do + path="$(command -v "$name" 2>/dev/null)" || continue + [ -n "$path" ] || continue + case "$path" in + "$VENV"/*) continue ;; + "$shim_dir"/*) ;; + *) log "WARNING: $name resolves to $path, outside uv's shim dir — left alone"; continue ;; + esac + write_venv_shim "$path" "$VENV/bin/$name" \ + && log "$path -> $VENV/bin/$name (so \`$name\` sees this repo's deps)" done - is_py312 /usr/local/bin/python3 \ - && log "/usr/local/bin/python{,3} -> $base_python" \ - || log "WARNING: /usr/local/bin/python3 is still $(/usr/local/bin/python3 -V 2>&1)" } # 3. The uv-managed tools — rebuilt on 3.12 only where they are not already. @@ -232,16 +342,34 @@ ensure_full_clone() { # stays "each repo owns its hook" and this file stays generated-from-one-source. # It takes effect on the next session start in this container. install_workspace_settings() { - # Only in the multi-repo layout: when the project dir IS this repo, Claude - # Code already found the repo's own settings and there is nothing to add. - [ -n "${CLAUDE_PROJECT_DIR:-}" ] || return 0 - [ "$(readlink -f "$CLAUDE_PROJECT_DIR")" != "$(readlink -f "$REPO_DIR")" ] || return 0 + # Target the WORKSPACE ROOT, derived from this checkout — not + # $CLAUDE_PROJECT_DIR, and not only when the two differ. + # + # The early return this replaces ("only in the multi-repo layout") made the + # whole leg unreachable. Writing the fan-out requires the hook to be + # running; the hook runs only in a session whose project dir is a repo — + # that is, a SINGLE-repo session — and the early return then skipped it as + # having nothing to add. So the one session type that could seed the + # container never did, and the multi-repo session that needed it never ran + # the hook to find out. Observed directly: a container with two single-repo + # sessions behind it still had no workspace-root settings, and the next + # session — three organs, project dir /home/user — fired no hook, ran on + # PATH's pytest, and left both clones shallow for three minutes until an + # unrelated verb happened to knock on session_bootstrap.sh. + # + # A single-repo session has the same sibling layout one directory up, so it + # can seed the root for free. Skip only if the root is itself a repo (then + # it owns its own hook) or is not writable. + local root="$WORKSPACE_ROOT" + [ -n "$root" ] && [ -d "$root" ] || return 0 + [ -d "$root/.git" ] && return 0 - local settings="$CLAUDE_PROJECT_DIR/.claude/settings.json" - local fanout="$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" - [ -w "$CLAUDE_PROJECT_DIR" ] || { log "WARNING: $CLAUDE_PROJECT_DIR not writable; multi-repo sessions will keep skipping the hook"; return 0; } + local settings="$root/.claude/settings.json" + local fanout="$root/.claude/hooks/session-start.sh" + [ -w "$root" ] || { log "WARNING: $root not writable; multi-repo sessions will keep skipping the hook"; return 0; } mkdir -p "$(dirname "$fanout")" + rm -f "$fanout" # never write through a symlink; see write_venv_shim cat >"$fanout" <<'FANOUT' #!/usr/bin/env bash # GENERATED at session start by a PyAuto repo's own session-start hook. @@ -283,6 +411,14 @@ SETTINGS # Git history and hook reachability have nothing to do with the interpreter, so # they run first and unconditionally: a container where the 3.12 venv cannot be # built still wants honest ancestry and a hook that fires next session. +# PYAUTO_SESSION_DEFINE_ONLY=1 defines every function and performs no action — +# the seam this hook's own tests use to drive one leg at a time against a +# temporary directory, instead of against the container they run in. Sourcing +# returns; executing exits. +if [ "${PYAUTO_SESSION_DEFINE_ONLY:-}" = "1" ]; then + return 0 2>/dev/null || exit 0 +fi + ensure_full_clone install_workspace_settings @@ -295,8 +431,9 @@ fi if ensure_venv; then ensure_repo_extras - point_system_default "$(readlink -f "$VENV/bin/python")" + point_system_default retool_uv_tools + point_pytest_at_venv # Every repo in the session registers this hook, so the second copy must not # prepend the venv a second time. if [ -n "${CLAUDE_ENV_FILE:-}" ] && ! grep -qs 'PYAUTO_SESSION_PY312=' "$CLAUDE_ENV_FILE"; then diff --git a/AGENTS.md b/AGENTS.md index f65d1338..82ac0ae6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,6 +160,21 @@ Like the other PyAuto repos, PyAutoBrain runs from its checkout (no pip install) it resolves the sibling `pyauto-heart` and `autohands` binaries from PATH or the `~/Code/PyAutoLabs/` checkouts. +## Remote sessions: two lines that pay for themselves + +Measured in a web/mobile container, where this file is loaded and little else is. + +- **Tests run 3.5x faster in parallel** — 4 cores, subprocess-heavy suites, no + single slow test: this repo's 554 tests are 96s on one core and 28s on four. + Use `python3 -m pytest -q -n auto`. +- **A session holding several organs registers no SessionStart hook.** Claude + Code reads project hooks from the project directory, which in that layout is + the repos' *parent*, not a repo. So knock on the door yourself in the first + turn: `bash PyAutoMind/scripts/session_bootstrap.sh` (add `--check` to report + only). `bin/pyauto-brain` already calls it before every verb. The symptom of + skipping it is a pytest that is not this workspace's — collection + `ImportError`s naming `yaml`, or `No module named pytest`. + ## The command surface (Brain implicit) The verb table above is the machinery; humans drive it through short commands