Skip to content

Commit f0dbdc9

Browse files
authored
Merge pull request #342 from PyAutoLabs/claude/mobile-performance-check-s8fyel
session: make a multi-repo remote session usable, and its tests 3.5x faster
2 parents 1d0d29c + 4998a95 commit f0dbdc9

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)