Skip to content

Commit 4998a95

Browse files
committed
session: make a multi-repo remote session usable, and its tests 3.5x faster
A second mobile-performance review, measuring what a web/mobile session actually costs. Python 3.12 is now the default (the last review's fix), but three defects underneath it were still live, and the two mitigations shipped last time both had holes. **The hook still never fires in a multi-repo session.** Proved from `~/.claude/session-env/<id>/`: two earlier single-repo sessions in this container each wrote an env file, this session's directory is empty. The workspace-root fan-out that was meant to fix it had never been installed by anyone — writing it requires the hook to be running, the hook only runs in a single-repo session, and an early return skipped exactly that session as "nothing to add". So the seed was unreachable by construction. It now installs from any session, targeting the workspace root derived from the checkout. **`python3 -m pytest` and `pytest` both failed, and neither looked like an environment problem.** With no env file there is no PATH export, so shells got `/usr/local/bin/python3` (no pytest) and uv's isolated `pytest` (no PyYAML — four collection ImportErrors that read like broken source, in a workspace whose suite was green). `point_system_default` was passing `readlink -f "$VENV/bin/python"`, resolving the venv straight through to the base interpreter. It now writes a wrapper — a symlink is resolved before CPython looks for `pyvenv.cfg`, which loses the venv again — and the venv is built `--system-site-packages` so pointing the default at it adds packages without removing any. The `pytest` on PATH is pointed at the venv's, confined to uv's own shim directory. **Tests ran on one of four cores.** No single slow test; 554 subprocess-heavy ones. pytest-xdist joins BASE_DEPS: PyAutoBrain 96s -> 28s, PyAutoMind 10s -> 3.7s, all passing either way. **`--check` reported "pytest: 3.12 OK" for that unusable pytest.** It asked the version, which is necessary and not sufficient; it now also asks whether the interpreter can import what the suite needs. One trap, hit while building the fix and now pinned by a test: the wrapper was written with `cat >`, `/usr/local/bin/python3` is a symlink, and a redirect opens the link's target — so it overwrote `/usr/bin/python3.12` itself with a wrapper that then exec'd itself. The container lost its interpreter. `rm -f` before writing, plus a chain walk refusing a target that links back through the destination. Validation: 232 PyAutoMind tests and 554 PyAutoBrain tests pass, `ruff`, `lifecycle.py check` and `repos_sync.py --check` clean. 8 new tests; each new guard was confirmed to FAIL with its fix removed. Follow-up: PyAutoHeart and PyAutoHands carry generated copies of this hook and are not attached to this session, so `repos_sync.py --write` regenerated only two of four. They need the same one-file update. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XLmJCBKNoh2tB5UkdecsSn
1 parent 1d0d29c commit 4998a95

5 files changed

Lines changed: 591 additions & 47 deletions

File tree

.claude/hooks/session-start.sh

Lines changed: 157 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ set -euo pipefail
4545
[ "${CLAUDE_CODE_REMOTE:-}" = "true" ] || exit 0
4646

4747
VENV="${PYAUTO_SESSION_VENV:-$HOME/.pyauto/session-py312}"
48-
BASE_DEPS=(pytest PyYAML)
48+
# pytest-xdist is a base dep, not a nicety: a remote container has 4 cores and
49+
# the Brain suite takes 96s on one of them and 28s on four. A session that has
50+
# to `pip install` before it can run tests fast will simply not run them fast.
51+
BASE_DEPS=(pytest PyYAML pytest-xdist)
4952

5053
# Which checkout is this copy of the hook installed in?
5154
#
@@ -100,7 +103,7 @@ find_base_python() {
100103
venv_ready() {
101104
is_py312 "$VENV/bin/python" \
102105
&& [ -x "$VENV/bin/pip" ] \
103-
&& "$VENV/bin/python" -c 'import pytest, yaml' >/dev/null 2>&1
106+
&& "$VENV/bin/python" -c 'import pytest, yaml, xdist' >/dev/null 2>&1
104107
}
105108

106109
# 1. The interpreter a session types: python, python3, pip, pytest.
@@ -117,13 +120,19 @@ ensure_venv() {
117120
log "building $VENV on $base_python"
118121
rm -rf "$VENV"
119122
mkdir -p "$(dirname "$VENV")"
123+
# --system-site-packages makes the venv a strict SUPERSET of the base
124+
# interpreter. That is what lets `point_system_default` make this venv the
125+
# session's `python3` without silently removing anything the image
126+
# installed: an isolated venv would swap one set of missing modules for
127+
# another, and the session would find out through a ModuleNotFoundError
128+
# that reads like broken code.
120129
if command -v uv >/dev/null 2>&1; then
121130
# --seed puts pip inside the venv too, so `pip install` targets 3.12
122131
# rather than falling through to the container's 3.11 /usr/bin/pip.
123-
uv venv --seed --python "$base_python" "$VENV" >&2
132+
uv venv --seed --system-site-packages --python "$base_python" "$VENV" >&2
124133
uv pip install --python "$VENV/bin/python" --quiet "${BASE_DEPS[@]}" >&2
125134
else
126-
"$base_python" -m venv "$VENV" >&2
135+
"$base_python" -m venv --system-site-packages "$VENV" >&2
127136
"$VENV/bin/python" -m pip install --quiet --upgrade pip >&2
128137
"$VENV/bin/python" -m pip install --quiet "${BASE_DEPS[@]}" >&2
129138
fi
@@ -152,16 +161,117 @@ ensure_repo_extras() {
152161
}
153162

154163
# 2. What PATH means without this session's env file.
164+
#
165+
# This leg is the one that matters most, because the env file is exactly what a
166+
# multi-repo session does NOT get: Claude Code registers project hooks from the
167+
# session's project directory, that directory is the repos' parent, and no hook
168+
# runs there (see leg 5). So every Bash call in such a session resolves whatever
169+
# the image put on PATH, and these two names are the fallback that has to be
170+
# right on its own.
171+
#
172+
# It used to point them at the BASE interpreter — `point_system_default
173+
# "$(readlink -f "$VENV/bin/python")"` resolved the venv's python straight
174+
# through to /usr/bin/python3.12. That satisfied the version question and lost
175+
# everything else: `python3 -m pytest` answered "No module named pytest", and
176+
# the session's own venv, sitting one directory away with pytest in it, was
177+
# unreachable from any shell.
178+
#
179+
# Two details make the fix work:
180+
#
181+
# * A wrapper script, not a symlink. Python resolves a symlinked executable
182+
# before looking for `pyvenv.cfg`, so a symlink to $VENV/bin/python lands on
183+
# the base interpreter's prefix and the venv is lost again — silently, and
184+
# in the same shape as the bug being fixed. `exec`ing it keeps the venv.
185+
# * --system-site-packages on the venv (see ensure_venv), so pointing the
186+
# system default here adds the session's packages without removing the
187+
# image's.
188+
# Bounded, because this predicate is what runs a candidate interpreter — and
189+
# the one failure mode worth surviving is an interpreter that never returns.
190+
venv_backed() {
191+
[ -x "$1" ] || return 1
192+
[ "$(timeout 20 "$1" -c 'import sys; print(sys.prefix)' 2>/dev/null)" = "$VENV" ]
193+
}
194+
195+
# Does following $1's symlinks pass through $2? A venv's python legitimately
196+
# RESOLVES to the same base interpreter a system default points at, so comparing
197+
# endpoints refuses safe rewrites. The question that matters is narrower: is the
198+
# path being rewritten a link in the target's own chain — because then the
199+
# wrapper execs itself.
200+
links_through() {
201+
local node="$1" needle="$2" hops=0
202+
needle="$(cd "$(dirname "$needle")" 2>/dev/null && printf '%s/%s' "$(pwd -P)" "$(basename "$needle")")"
203+
while [ -L "$node" ] && [ "$hops" -lt 40 ]; do
204+
node="$(cd "$(dirname "$node")" && cd "$(dirname "$(readlink "$node")")" 2>/dev/null && printf '%s/%s' "$(pwd -P)" "$(basename "$(readlink "$node")")")"
205+
[ "$node" = "$needle" ] && return 0
206+
hops=$((hops + 1))
207+
done
208+
return 1
209+
}
210+
211+
# `rm -f` first, and it is not tidiness. /usr/local/bin/python3 is a SYMLINK to
212+
# the real interpreter, and a redirect opens the link's TARGET: `cat >` there
213+
# overwrites /usr/bin/python3.12 itself with this wrapper. The venv's own
214+
# python symlinks to that same file, so the wrapper then execs itself — the
215+
# container loses its interpreter and every `python3` spins at 100% CPU. Writing
216+
# a fresh file at the path replaces the link instead of following it.
217+
#
218+
# `links_through` is the second half of the same guard: a target that reaches
219+
# the destination through its own symlink chain builds the identical loop by the
220+
# other route, so refuse rather than write it.
221+
write_venv_shim() {
222+
local dest="$1" target="$2"
223+
[ -w "$(dirname "$dest")" ] || { log "WARNING: cannot rewrite $dest (not writable)"; return 1; }
224+
[ -x "$target" ] || { log "WARNING: $target is not executable; leaving $dest alone"; return 1; }
225+
if links_through "$target" "$dest"; then
226+
log "WARNING: refusing to point $dest at $target — it links back through $dest"
227+
return 1
228+
fi
229+
rm -f "$dest"
230+
cat >"$dest" <<SHIM
231+
#!/bin/sh
232+
# GENERATED by PyAutoMind's session-start hook. A wrapper, not a symlink:
233+
# Python resolves a symlink before reading pyvenv.cfg, which loses the venv.
234+
exec "$target" "\$@"
235+
SHIM
236+
chmod 0755 "$dest"
237+
}
238+
155239
point_system_default() {
156-
local base_python="$1" link
157-
for link in /usr/local/bin/python /usr/local/bin/python3; do
158-
is_py312 "$link" && continue
159-
[ -w "$(dirname "$link")" ] || { log "WARNING: cannot rewrite $link (not writable)"; continue; }
160-
ln -sfn "$base_python" "$link"
240+
local link bin="${PYAUTO_SESSION_SYSTEM_BIN:-/usr/local/bin}"
241+
for link in "$bin/python" "$bin/python3"; do
242+
venv_backed "$link" && continue
243+
write_venv_shim "$link" "$VENV/bin/python" || continue
244+
done
245+
venv_backed "$bin/python3" \
246+
&& log "$bin/python{,3} -> $VENV/bin/python (venv, with pytest and yaml)" \
247+
|| log "WARNING: $bin/python3 is still $(timeout 20 "$bin/python3" -V 2>&1)"
248+
}
249+
250+
# 2b. The `pytest` that PATH actually finds.
251+
#
252+
# $HOME/.local/bin precedes /usr/local/bin, and it holds uv's tool shims — so
253+
# fixing python3 alone still leaves bare `pytest` resolving to uv's ISOLATED
254+
# pytest, which by design cannot see PyYAML or a repo's own extras. In this
255+
# workspace that made `pytest` exit on four collection ImportErrors that read
256+
# like broken source, in a session where the suite was in fact green.
257+
#
258+
# A session should have exactly one pytest, and it should be the venv's.
259+
# Confined to uv's own shim directory: a distro-packaged pytest is not ours to
260+
# overwrite. Runs after retool_uv_tools, which rewrites these same shims.
261+
point_pytest_at_venv() {
262+
local shim_dir="${HOME}/.local/bin" name path
263+
[ -x "$VENV/bin/pytest" ] || return 0
264+
for name in pytest py.test; do
265+
path="$(command -v "$name" 2>/dev/null)" || continue
266+
[ -n "$path" ] || continue
267+
case "$path" in
268+
"$VENV"/*) continue ;;
269+
"$shim_dir"/*) ;;
270+
*) log "WARNING: $name resolves to $path, outside uv's shim dir — left alone"; continue ;;
271+
esac
272+
write_venv_shim "$path" "$VENV/bin/$name" \
273+
&& log "$path -> $VENV/bin/$name (so \`$name\` sees this repo's deps)"
161274
done
162-
is_py312 /usr/local/bin/python3 \
163-
&& log "/usr/local/bin/python{,3} -> $base_python" \
164-
|| log "WARNING: /usr/local/bin/python3 is still $(/usr/local/bin/python3 -V 2>&1)"
165275
}
166276

167277
# 3. The uv-managed tools — rebuilt on 3.12 only where they are not already.
@@ -232,16 +342,34 @@ ensure_full_clone() {
232342
# stays "each repo owns its hook" and this file stays generated-from-one-source.
233343
# It takes effect on the next session start in this container.
234344
install_workspace_settings() {
235-
# Only in the multi-repo layout: when the project dir IS this repo, Claude
236-
# Code already found the repo's own settings and there is nothing to add.
237-
[ -n "${CLAUDE_PROJECT_DIR:-}" ] || return 0
238-
[ "$(readlink -f "$CLAUDE_PROJECT_DIR")" != "$(readlink -f "$REPO_DIR")" ] || return 0
345+
# Target the WORKSPACE ROOT, derived from this checkout — not
346+
# $CLAUDE_PROJECT_DIR, and not only when the two differ.
347+
#
348+
# The early return this replaces ("only in the multi-repo layout") made the
349+
# whole leg unreachable. Writing the fan-out requires the hook to be
350+
# running; the hook runs only in a session whose project dir is a repo —
351+
# that is, a SINGLE-repo session — and the early return then skipped it as
352+
# having nothing to add. So the one session type that could seed the
353+
# container never did, and the multi-repo session that needed it never ran
354+
# the hook to find out. Observed directly: a container with two single-repo
355+
# sessions behind it still had no workspace-root settings, and the next
356+
# session — three organs, project dir /home/user — fired no hook, ran on
357+
# PATH's pytest, and left both clones shallow for three minutes until an
358+
# unrelated verb happened to knock on session_bootstrap.sh.
359+
#
360+
# A single-repo session has the same sibling layout one directory up, so it
361+
# can seed the root for free. Skip only if the root is itself a repo (then
362+
# it owns its own hook) or is not writable.
363+
local root="$WORKSPACE_ROOT"
364+
[ -n "$root" ] && [ -d "$root" ] || return 0
365+
[ -d "$root/.git" ] && return 0
239366

240-
local settings="$CLAUDE_PROJECT_DIR/.claude/settings.json"
241-
local fanout="$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh"
242-
[ -w "$CLAUDE_PROJECT_DIR" ] || { log "WARNING: $CLAUDE_PROJECT_DIR not writable; multi-repo sessions will keep skipping the hook"; return 0; }
367+
local settings="$root/.claude/settings.json"
368+
local fanout="$root/.claude/hooks/session-start.sh"
369+
[ -w "$root" ] || { log "WARNING: $root not writable; multi-repo sessions will keep skipping the hook"; return 0; }
243370

244371
mkdir -p "$(dirname "$fanout")"
372+
rm -f "$fanout" # never write through a symlink; see write_venv_shim
245373
cat >"$fanout" <<'FANOUT'
246374
#!/usr/bin/env bash
247375
# GENERATED at session start by a PyAuto repo's own session-start hook.
@@ -283,6 +411,14 @@ SETTINGS
283411
# Git history and hook reachability have nothing to do with the interpreter, so
284412
# they run first and unconditionally: a container where the 3.12 venv cannot be
285413
# built still wants honest ancestry and a hook that fires next session.
414+
# PYAUTO_SESSION_DEFINE_ONLY=1 defines every function and performs no action —
415+
# the seam this hook's own tests use to drive one leg at a time against a
416+
# temporary directory, instead of against the container they run in. Sourcing
417+
# returns; executing exits.
418+
if [ "${PYAUTO_SESSION_DEFINE_ONLY:-}" = "1" ]; then
419+
return 0 2>/dev/null || exit 0
420+
fi
421+
286422
ensure_full_clone
287423
install_workspace_settings
288424

@@ -295,8 +431,9 @@ fi
295431

296432
if ensure_venv; then
297433
ensure_repo_extras
298-
point_system_default "$(readlink -f "$VENV/bin/python")"
434+
point_system_default
299435
retool_uv_tools
436+
point_pytest_at_venv
300437
# Every repo in the session registers this hook, so the second copy must not
301438
# prepend the venv a second time.
302439
if [ -n "${CLAUDE_ENV_FILE:-}" ] && ! grep -qs 'PYAUTO_SESSION_PY312=' "$CLAUDE_ENV_FILE"; then

AGENTS.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,33 @@ For the full workflow narrative, conventions, and registry schemas, read
9191
loosely-related changes, split into separate prompt files before issuing.
9292
4. **`tmp/` is scratch.** Never commit anything under it.
9393

94+
## Running tests in a remote (web/mobile) session
95+
96+
Two facts, both measured, both worth one line each:
97+
98+
1. **Run the suite in parallel.** A remote container has 4 cores and the suites
99+
are subprocess-heavy with no single slow test: PyAutoBrain's 554 tests take
100+
96s on one core and 28s on four. `pytest-xdist` is installed by the
101+
session-start hook, so the command is just:
102+
103+
```
104+
python3 -m pytest -q -n auto
105+
```
106+
107+
2. **If `python3 -m pytest` or `pytest` misbehaves, the environment is stale,
108+
not the code.** A session holding several organs registers no SessionStart
109+
hook (Claude Code reads hooks from the project directory, which is the
110+
repos' *parent*). Knock on the door directly, once, in the first turn:
111+
112+
```
113+
bash PyAutoMind/scripts/session_bootstrap.sh # fix it
114+
bash PyAutoMind/scripts/session_bootstrap.sh --check # report only
115+
```
116+
117+
The symptom to recognise: collection `ImportError`s naming `yaml`, or
118+
`No module named pytest`. Both are the session resolving a pytest that is not
119+
this workspace's — never a broken test module.
120+
94121
## When you are asked to add a new prompt
95122

96123
Write the file under `draft/<work-type>/<target>/<name>.md` — pick the work-type

0 commit comments

Comments
 (0)