From a8821717ebdec0c932bd2463f2213b4085f847da Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:36:40 +0000 Subject: [PATCH 01/18] claude-current and claude-restart-check: launch only the newest published Claude Code, and tell a running session when its binary moved For opensoft/workBenches#119, on Brett Heap's rulings of 2026-09-29: the home decision ("this work is really for openRepoTools repo") puts the resolver here, installed by `openRepoTools --install`, and point 2 ("for 2, we can run update on every start, this ensures we have the latest models") makes every launch run the check. claude-current resolves the executable by ABSOLUTE PATH, never through PATH: the newest native version, the user npm prefix, ~/.local/bin/claude, then the image's copies, each read with --version and read once per physical file. It asks npm for the published version with a bound, updates the user-writable install under a lock when every candidate is behind (claude update for a native install, else npm install -g --prefix plus the package's install.cjs that npm 12 skips), and hands back the path whose version equals npm. An equal candidate beats a newer one, and a newer one launches as ahead. Offline, it launches unverified. Still behind after the update, it refuses (exit 2) unless CLAUDE_ALLOW_STALE=1. --porcelain gives path/version/published/status, and --offline reads no npm. claude-restart-check prints one green RESTART NEEDED line when the claude process above it (at most three processes up) runs a binary since replaced on disk (exe ends " (deleted)") or older than the installed one, read from the native versions directory or the package's package.json. It reads /proc and two small files, runs no binary, reaches no network, and always exits 0. Tests: tests/test_claude_current.py and tests/test_claude_restart_check.py, both hermetic, and both commands held to test_repo_hygiene.py's bash rules. The macOS job parses both with /bin/bash -n. Refs opensoft/workBenches#119 Lane: openxfactory-5 (openXfactory-5) Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 2 + claude-current | 585 +++++++++++++++++++++++++++++ claude-restart-check | 236 ++++++++++++ tests/test_claude_current.py | 551 +++++++++++++++++++++++++++ tests/test_claude_restart_check.py | 204 ++++++++++ 5 files changed, 1578 insertions(+) create mode 100755 claude-current create mode 100755 claude-restart-check create mode 100644 tests/test_claude_current.py create mode 100644 tests/test_claude_restart_check.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2ea7c54..f892828 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -231,6 +231,8 @@ jobs: /bin/bash -n lane-start /bin/bash -n lane-end /bin/bash -n link-estates + /bin/bash -n claude-current + /bin/bash -n claude-restart-check /bin/bash -n tests/test_lane_helpers.sh /bin/bash -n tests/run.sh diff --git a/claude-current b/claude-current new file mode 100755 index 0000000..b506a04 --- /dev/null +++ b/claude-current @@ -0,0 +1,585 @@ +#!/usr/bin/env bash +# claude-current: the Claude Code executable a launch should start. That is +# the newest version npm publishes, verified with `--version`, and it is named +# by an ABSOLUTE PATH. `claude` is never resolved through PATH. +# +# Why it exists (opensoft/workBenches#119, Brett Heap's rulings of 2026-09-29): +# a lane started on an old Claude Code, and nothing noticed. Two installs +# competed on PATH, the image's copy frozen at the image build and the user's +# own npm copy, and a session that had run 2.1.283 for twenty hours lacked a +# model a fresh `claude` listed. The home ruling puts the resolver here, +# installed by `openRepoTools --install`. Point 2 is verbatim "for 2, we can +# run update on every start, this ensures we have the latest models", so every +# launch runs this check. +# +# USAGE +# claude-current [--porcelain] [--offline] +# claude-current --help +# +# WHAT IT DOES +# 1. Reads the published version with `npm view @anthropic-ai/claude-code +# version`, bounded by $CLAUDE_CURRENT_TIMEOUT seconds (default 10). +# --offline skips this step and step 3. +# 2. Reads each installed candidate's `--version`, by absolute path, in this +# order: +# - the newest native version in $CLAUDE_CURRENT_NATIVE_DIR (default +# ~/.local/share/claude/versions), by its x.y.z name; +# - /bin/claude. The prefix is $CLAUDE_CURRENT_NPM_PREFIX, +# else $NPM_CONFIG_PREFIX, else $npm_config_prefix, else the +# `prefix=` line of ~/.npmrc, else ~/.npm-global; +# - ~/.local/bin/claude; +# - each entry of $CLAUDE_CURRENT_SYSTEM_CANDIDATES (default +# /usr/local/bin/claude:/usr/bin/claude, the image's copies). +# A path that resolves to one already read is read once, and the first +# spelling, the user-writable one, is kept. +# 3. When every candidate is behind the published version, or there is none, +# it updates the user-writable install under a lock and reads again. A +# native install gets `claude update`. When that does not reach the +# published version, or there is no native install, it runs +# `npm install -g --prefix @anthropic-ai/claude-code@` +# and then the package's own install.cjs if `--version` still differs, +# because npm 12 skips the package's native-binary hook. A second launch +# waits on the lock and reads again before it updates anything itself. +# 4. Picks the first candidate whose version EQUALS the published one +# (verified). Else it picks the newest candidate AHEAD of it (ahead). +# Else it refuses (stale), unless CLAUDE_ALLOW_STALE=1, when it picks the +# newest (stale). When npm could not be read, it picks the newest +# candidate (unverified). +# +# OUTPUT +# stdout the absolute path, one line. With --porcelain, exactly four +# lines: path=, version=, +# published=, and +# status=. A refusal prints nothing +# on stdout. +# stderr one line saying what was chosen, for example +# "claude-current: claude 2.1.284 (verified against npm 2.1.284) at ". +# An update prints its own progress there too. +# +# EXIT +# 0 resolved: verified, ahead or unverified, and stale only under +# CLAUDE_ALLOW_STALE=1 +# 1 no runnable Claude Code at all, or a malformed setting +# 2 refused: every candidate is still behind npm after the update attempt +# 64 usage +# +# ENVIRONMENT +# CLAUDE_ALLOW_STALE=1 launch a stale candidate anyway, and say so +# CLAUDE_CURRENT_NPM the npm to run (default: npm) +# CLAUDE_CURRENT_NODE the node that runs install.cjs (default: node) +# CLAUDE_CURRENT_TIMEOUT seconds for `npm view` (default 10) +# CLAUDE_CURRENT_VERSION_TIMEOUT seconds for each `--version` (default 10) +# CLAUDE_CURRENT_UPDATE_TIMEOUT seconds for each update command (default 300) +# CLAUDE_CURRENT_LOCK_WAIT seconds to wait for another launch's update +# (default 330) +# CLAUDE_CURRENT_CACHE_DIR where the lock lives (default +# ${XDG_CACHE_HOME:-~/.cache}/openrepotools) +# CLAUDE_CURRENT_NPM_PREFIX the user npm prefix, see step 2 +# CLAUDE_CURRENT_NATIVE_DIR the native versions directory, see step 2 +# CLAUDE_CURRENT_SYSTEM_CANDIDATES colon-separated image paths, see step 2. +# Set it empty for none. +# +# HANDING THE RESULT ON (docs/README-claude-current.md) +# A launcher that starts the path exports CLAUDE_BIN=, +# CLAUDE_RESOLVED_BIN= and CLAUDE_VERIFIED_VERSION=. +# `lane-start` records `claude ` on the register's launch line and +# keeps CLAUDE_VERIFIED_VERSION out of the session's environment. A later +# launch that finds CLAUDE_BIN equal to CLAUDE_RESOLVED_BIN, with no +# CLAUDE_VERIFIED_VERSION, knows the value was inherited from a session and +# is not an operator's pin, and resolves again. +# +# Bash 3.2 safe (macOS): no mapfile, no associative arrays, no readlink -f, +# no GNU-only flags; `timeout`, else `gtimeout`, else a watchdog; `flock`, else +# a mkdir lock. + +set -euo pipefail + +prog="claude-current" +PACKAGE="@anthropic-ai/claude-code" + +usage() { + cat <<'USAGE' +claude-current [--porcelain] [--offline] +claude-current --help + +Print the absolute path of the Claude Code a launch should start: the version +npm publishes, verified with --version, updating the user-writable install +first when every installed copy is behind. Never resolved through PATH. + + --porcelain four lines on stdout: path=, version=, published=, status= + (status is verified, ahead, unverified or stale) + --offline no npm read and no update: the newest installed copy, unverified + +Exit 0 resolved, 1 nothing runnable, 2 refused as stale, 64 usage. +CLAUDE_ALLOW_STALE=1 launches a stale copy anyway. The CLAUDE_CURRENT_* +variables and the hand-off to lane-start are in docs/README-claude-current.md. +USAGE +} + +say() { printf '%s: %s\n' "$prog" "$*" >&2; } +die() { printf '%s: %s\n' "$prog" "$1" >&2; exit "${2:-1}"; } + +porcelain=0 +offline=0 +while [ $# -gt 0 ]; do + case "$1" in + --porcelain) porcelain=1 ;; + --offline) offline=1 ;; + -h | --help) usage; exit 0 ;; + *) + printf '%s: unknown argument: %s\n' "$prog" "$1" >&2 + usage >&2 + exit 64 ;; + esac + shift +done + +# ------------------------------------------------------------------ settings + +posint() { + case "${1:-}" in + '' | *[!0-9]*) return 1 ;; + esac + [ "$1" -gt 0 ] +} + +NPM="${CLAUDE_CURRENT_NPM:-npm}" +NODE="${CLAUDE_CURRENT_NODE:-node}" +TIMEOUT="${CLAUDE_CURRENT_TIMEOUT:-10}" +VERSION_TIMEOUT="${CLAUDE_CURRENT_VERSION_TIMEOUT:-10}" +UPDATE_TIMEOUT="${CLAUDE_CURRENT_UPDATE_TIMEOUT:-300}" +LOCK_WAIT="${CLAUDE_CURRENT_LOCK_WAIT:-330}" +for setting in TIMEOUT VERSION_TIMEOUT UPDATE_TIMEOUT LOCK_WAIT; do + eval "setting_value=\${$setting}" + # shellcheck disable=SC2154 + posint "$setting_value" || + die "CLAUDE_CURRENT_$setting must be a whole number of seconds above 0, and it is '$setting_value'" 1 +done +NATIVE_DIR="${CLAUDE_CURRENT_NATIVE_DIR:-$HOME/.local/share/claude/versions}" +CACHE_DIR="${CLAUDE_CURRENT_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/openrepotools}" +LOCK_FILE="$CACHE_DIR/claude-current.lock" + +# A version is x.y.z, optionally with a pre-release or build tail. +version_re='^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.+-]+)?$' +core_re='^[0-9]+\.[0-9]+\.[0-9]+$' +is_version() { [[ "${1:-}" =~ $version_re ]]; } + +# ------------------------------------------------------------ small helpers + +# run_bounded [args...]: the command's own status, or 124 +# when it ran out of time. GNU `timeout`, else Homebrew's `gtimeout`, else a +# watchdog, because a stock macOS ships neither. The watchdog's output goes to +# /dev/null so that it never holds a caller's command substitution open. +run_bounded() { + local secs="$1" pid dog rc=0 + shift + if command -v timeout >/dev/null 2>&1; then + timeout "$secs" "$@" || rc=$? + return "$rc" + fi + if command -v gtimeout >/dev/null 2>&1; then + gtimeout "$secs" "$@" || rc=$? + return "$rc" + fi + "$@" & + pid=$! + ( sleep "$secs"; kill -TERM "$pid" 2>/dev/null ) >/dev/null 2>&1 & + dog=$! + wait "$pid" || rc=$? + kill "$dog" 2>/dev/null || : + wait "$dog" 2>/dev/null || : + [ "$rc" = 143 ] && rc=124 + return "$rc" +} + +# numcmp : -1, 0 or 1 for two digit strings, compared as numbers of any +# length without integer arithmetic (the same rule as workBenches #109). +numcmp() { + local x="$1" y="$2" + x="${x#"${x%%[!0]*}"}" + y="${y#"${y%%[!0]*}"}" + x="${x:-0}" + y="${y:-0}" + if [ "${#x}" -ne "${#y}" ]; then + if [ "${#x}" -gt "${#y}" ]; then echo 1; else echo -1; fi + return 0 + fi + if [ "$x" = "$y" ]; then echo 0; return 0; fi + if [[ "$x" > "$y" ]]; then echo 1; else echo -1; fi +} + +# vercmp : -1, 0 or 1. Numeric on the x.y.z core. Then a version with no +# pre-release tag beats one with a tag, and two tags compare as strings. Build +# metadata (+...) is ignored. +vercmp() { + local a="${1%%+*}" b="${2%%+*}" ac bc ap="" bp="" ar br i c + ac="${a%%-*}" + bc="${b%%-*}" + case "$a" in *-*) ap="${a#*-}" ;; esac + case "$b" in *-*) bp="${b#*-}" ;; esac + for i in 1 2 3; do + ar="${ac%%.*}" + br="${bc%%.*}" + c="$(numcmp "$ar" "$br")" + [ "$c" = 0 ] || { echo "$c"; return 0; } + ac="${ac#*.}" + bc="${bc#*.}" + done + if [ "$ap" = "$bp" ]; then echo 0; return 0; fi + if [ -z "$ap" ]; then echo 1; return 0; fi + if [ -z "$bp" ]; then echo -1; return 0; fi + if [[ "$ap" > "$bp" ]]; then echo 1; else echo -1; fi +} + +# physical : the path with every symlink resolved, without `readlink -f`, +# which BSD `readlink` did not take until macOS 12.3. +physical() { + local p="$1" link n=0 dir + while [ -L "$p" ] && [ "$n" -lt 40 ]; do + link="$(readlink -- "$p")" || break + case "$link" in + /*) p="$link" ;; + *) p="$(dirname -- "$p")/$link" ;; + esac + n=$((n + 1)) + done + dir="$(cd -P -- "$(dirname -- "$p")" 2>/dev/null && pwd)" || { + printf '%s\n' "$p" + return 0 + } + printf '%s/%s\n' "$dir" "$(basename -- "$p")" +} + +# read_version : the first version on the first line of ` --version`. +read_version() { + local out="" v + out="$(run_bounded "$VERSION_TIMEOUT" "$1" --version 2>/dev/null " line per candidate, in order +seen=$'\n' # the physical paths already read +native_path="" # the native candidate, when there is one + +add_candidate() { + local p="$1" real v + case "$p" in /*) ;; *) return 0 ;; esac + [ -f "$p" ] && [ -x "$p" ] || return 0 + real="$(physical "$p")" + case "$seen" in *$'\n'"$real"$'\n'*) return 0 ;; esac + seen="$seen$real"$'\n' + v="$(read_version "$p")" || return 0 + cands="$cands$p"$'\t'"$v"$'\n' +} + +collect() { + local p old_ifs + cands="" + seen=$'\n' + native_path="$(native_newest)" + add_candidate "$native_path" + add_candidate "$USER_PREFIX/bin/claude" + add_candidate "$HOME/.local/bin/claude" + old_ifs="$IFS" + set -f + IFS=: + for p in ${CLAUDE_CURRENT_SYSTEM_CANDIDATES-/usr/local/bin/claude:/usr/bin/claude}; do + IFS="$old_ifs" + add_candidate "$p" + IFS=: + done + IFS="$old_ifs" + set +f +} + +# pick []: sets CHOSEN_PATH and CHOSEN_VERSION, or returns 1. +# equal the first candidate whose version equals +# ahead the newest candidate newer than +# newest the newest candidate at all (the first of equal ones) +pick() { + local mode="$1" ref="${2:-}" p v c best="" bestv="" + while IFS=$'\t' read -r p v; do + [ -n "$p" ] || continue + case "$mode" in + equal) + if [ "$(vercmp "$v" "$ref")" = 0 ]; then + CHOSEN_PATH="$p" + CHOSEN_VERSION="$v" + return 0 + fi + continue ;; + ahead) + [ "$(vercmp "$v" "$ref")" = 1 ] || continue ;; + esac + if [ -z "$best" ]; then + best="$p" + bestv="$v" + continue + fi + c="$(vercmp "$v" "$bestv")" + if [ "$c" = 1 ]; then + best="$p" + bestv="$v" + fi + done </dev/null || return 1 + if command -v flock >/dev/null 2>&1; then + exec 9>>"$LOCK_FILE" || return 1 + lock_fd_open=1 + if flock -w "$LOCK_WAIT" 9; then + return 0 + fi + exec 9>&- + lock_fd_open=0 + return 1 + fi + # No flock on a stock macOS: a mkdir lock whose owner writes its pid, so a + # lock left by a process that died is taken over, not waited out. + d="$LOCK_FILE.d" + while ! mkdir -- "$d" 2>/dev/null; do + owner="$(cat -- "$d/pid" 2>/dev/null || :)" + if [ -n "$owner" ] && ! kill -0 "$owner" 2>/dev/null; then + rm -rf -- "$d" + continue + fi + [ "$waited" -lt "$LOCK_WAIT" ] || return 1 + sleep 1 + waited=$((waited + 1)) + done + printf '%s\n' "$$" >"$d/pid" + mkdir_lock="$d" + return 0 +} + +release_lock() { + if [ "$lock_fd_open" = 1 ]; then + flock -u 9 2>/dev/null || : + exec 9>&- + lock_fd_open=0 + fi + if [ -n "$mkdir_lock" ]; then + rm -rf -- "$mkdir_lock" + mkdir_lock="" + fi +} +trap release_lock EXIT + +# ------------------------------------------------------------ the update + +update_why="" + +update_native() { + local rc=0 + say "updating the native install toward $PUBLISHED: $native_path update" + run_bounded "$UPDATE_TIMEOUT" "$native_path" update &2 9>&- || rc=$? + [ "$rc" = 0 ] || update_why="\`$native_path update\` exited $rc" + return "$rc" +} + +update_npm() { + local rc=0 v hook + if ! command -v "$NPM" >/dev/null 2>&1; then + update_why="there is no npm ('$NPM') to install $PACKAGE@$PUBLISHED with" + return 1 + fi + if ! mkdir -p -- "$USER_PREFIX" 2>/dev/null; then + update_why="the user npm prefix $USER_PREFIX could not be created" + return 1 + fi + say "updating $USER_PREFIX to claude $PUBLISHED: $NPM install -g --prefix $USER_PREFIX $PACKAGE@$PUBLISHED" + run_bounded "$UPDATE_TIMEOUT" "$NPM" install -g --prefix "$USER_PREFIX" --no-fund --no-audit \ + "$PACKAGE@$PUBLISHED" &2 9>&- || rc=$? + if [ "$rc" != 0 ]; then + update_why="\`$NPM install -g --prefix $USER_PREFIX $PACKAGE@$PUBLISHED\` exited $rc" + return "$rc" + fi + # npm 12's install-script policy skips the package's native-binary hook, so + # the launcher it links can be a stub until that hook runs. The shared image + # runs the same hook by hand (workBenches base-image/install-ai-clis.sh). + v="$(read_version "$USER_PREFIX/bin/claude" || :)" + hook="$USER_PREFIX/lib/node_modules/$PACKAGE/install.cjs" + if [ "$v" != "$PUBLISHED" ] && [ -f "$hook" ]; then + say "running the package's own install hook, which npm skipped: $NODE $hook" + rc=0 + run_bounded "$UPDATE_TIMEOUT" "$NODE" "$hook" &2 9>&- || rc=$? + [ "$rc" = 0 ] || update_why="the package's install hook ($hook) exited $rc" + fi + return 0 +} + +# ------------------------------------------------------------ the answer + +PUBLISHED="" +CHOSEN_PATH="" +CHOSEN_VERSION="" + +answer() { # + say "$2" + if [ "$porcelain" = 1 ]; then + printf 'path=%s\nversion=%s\npublished=%s\nstatus=%s\n' \ + "$CHOSEN_PATH" "$CHOSEN_VERSION" "$PUBLISHED" "$1" + else + printf '%s\n' "$CHOSEN_PATH" + fi + exit 0 +} + +none_installed() { # + die "no runnable Claude Code was found by absolute path ($1). Looked in: $NATIVE_DIR, $USER_PREFIX/bin/claude, $HOME/.local/bin/claude and ${CLAUDE_CURRENT_SYSTEM_CANDIDATES-/usr/local/bin/claude:/usr/bin/claude}. Install it with: npm install -g --prefix $USER_PREFIX $PACKAGE" 1 +} + +collect + +if [ "$offline" = 1 ]; then + pick newest || none_installed "--offline reads no npm and installs nothing" + answer unverified "claude $CHOSEN_VERSION at $CHOSEN_PATH (--offline: not checked against npm)" +fi + +published_why="" +if ! command -v "$NPM" >/dev/null 2>&1; then + published_why="there is no npm ('$NPM') to ask" +else + view_rc=0 + view_out="$(run_bounded "$TIMEOUT" "$NPM" view "$PACKAGE" version --prefer-online 2>/dev/null ] [--pid ] +# claude-restart-check --help +# +# --running the version the session reports, from the status +# line's JSON `version` field +# --pid where the walk to the claude process starts (default: +# this command's parent). The status line passes its own +# $PPID, the `/bin/sh -c` that Claude starts it under. +# +# HOW IT DECIDES, cheaply, because it runs at every status line render +# - Walks at most three processes, from --pid up through its parents, to +# the first one whose /proc//exe is a Claude Code binary: a native +# version file (.../claude/versions/) or the npm package's +# .../node_modules/@anthropic-ai//bin/claude.exe. +# - The binary was REPLACED when that exe link ends in " (deleted)". npm +# renames the old package directory before it deletes it, so the exe path +# cannot be used to read the running version. +# - The INSTALLED version is read from disk: the highest x.y.z name in the +# native versions directory, or `version` in the npm package's +# package.json at its canonical path. +# - It warns when the binary was replaced, or when the installed version is +# newer than the running one (--running, else the native file's name). +# It reads /proc and two small files. It reaches no network and runs no +# binary, so the render is not slowed by a `claude --version`. +# +# OUTPUT +# Nothing, or exactly one line: +# RESTART NEEDED: running , installed ; /ctx at your next breakpoint +# in green, or plain when NO_COLOR is set. +# +# EXIT +# 0 always, including a host with no /proc such as macOS, where it prints +# nothing, because a status line must never break on this. 64 only for an +# argument it does not know. +# +# ENVIRONMENT +# CLAUDE_RESTART_CHECK_PROC the process table to read (default /proc), the +# test seam +# NO_COLOR plain text, no ANSI colour +# +# `set -u` and NOT `set -e`, deliberately, for lane-handoff's reason one file +# over: every read here may fail (a process gone mid-walk, a file unreadable), +# and each failure must reach this command's own "print nothing, exit 0" +# rather than end the status line's render with a non-zero status. + +set -u + +prog="claude-restart-check" + +usage() { + cat <<'USAGE' +claude-restart-check [--running ] [--pid ] +claude-restart-check --help + +Print one green "RESTART NEEDED: running , installed ; /ctx at your +next breakpoint" line when the Claude Code session above this process runs a +binary since replaced on disk, or one older than the version installed; print +nothing otherwise. Always exits 0 (64 for an unknown argument). Reads /proc +(CLAUDE_RESTART_CHECK_PROC) and never acts on the session. +USAGE +} + +running="" +start_pid="" +while [ $# -gt 0 ]; do + case "$1" in + --running) + [ $# -ge 2 ] || { usage >&2; exit 64; } + running="$2" + shift 2 ;; + --running=*) + running="${1#--running=}" + shift ;; + --pid) + [ $# -ge 2 ] || { usage >&2; exit 64; } + start_pid="$2" + shift 2 ;; + --pid=*) + start_pid="${1#--pid=}" + shift ;; + -h | --help) + usage + exit 0 ;; + *) + printf '%s: unknown argument: %s\n' "$prog" "$1" >&2 + usage >&2 + exit 64 ;; + esac +done + +PROC="${CLAUDE_RESTART_CHECK_PROC:-/proc}" +[ -n "$start_pid" ] || start_pid="$PPID" +case "$start_pid" in + '' | *[!0-9]*) exit 0 ;; +esac +[ -d "$PROC" ] || exit 0 + +version_re='^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.+-]+)?$' +core_re='^[0-9]+\.[0-9]+\.[0-9]+$' +is_version() { [[ "${1:-}" =~ $version_re ]]; } + +# numcmp and vercmp are claude-current's, byte for byte, so the two commands +# agree on which version is newer. +numcmp() { + local x="$1" y="$2" + x="${x#"${x%%[!0]*}"}" + y="${y#"${y%%[!0]*}"}" + x="${x:-0}" + y="${y:-0}" + if [ "${#x}" -ne "${#y}" ]; then + if [ "${#x}" -gt "${#y}" ]; then echo 1; else echo -1; fi + return 0 + fi + if [ "$x" = "$y" ]; then echo 0; return 0; fi + if [[ "$x" > "$y" ]]; then echo 1; else echo -1; fi +} + +vercmp() { + local a="${1%%+*}" b="${2%%+*}" ac bc ap="" bp="" ar br i c + ac="${a%%-*}" + bc="${b%%-*}" + case "$a" in *-*) ap="${a#*-}" ;; esac + case "$b" in *-*) bp="${b#*-}" ;; esac + for i in 1 2 3; do + ar="${ac%%.*}" + br="${bc%%.*}" + c="$(numcmp "$ar" "$br")" + [ "$c" = 0 ] || { echo "$c"; return 0; } + ac="${ac#*.}" + bc="${bc#*.}" + done + if [ "$ap" = "$bp" ]; then echo 0; return 0; fi + if [ -z "$ap" ]; then echo 1; return 0; fi + if [ -z "$bp" ]; then echo -1; return 0; fi + if [[ "$ap" > "$bp" ]]; then echo 1; else echo -1; fi +} + +is_claude_binary() { # + case "$1" in + */claude/versions/*) return 0 ;; + */node_modules/@anthropic-ai/*/bin/claude.exe) return 0 ;; + */node_modules/@anthropic-ai/*/bin/claude) return 0 ;; + esac + return 1 +} + +parent_of() { + awk '/^PPid:/ { print $2; exit }' "$PROC/$1/status" 2>/dev/null +} + +# The highest x.y.z name among the executable regular files of one directory. +highest_in() { + local f name best="" + for f in "$1"/*; do + [ -f "$f" ] && [ -x "$f" ] && [ ! -L "$f" ] || continue + name="${f##*/}" + [[ "$name" =~ $core_re ]] || continue + if [ -z "$best" ] || [ "$(vercmp "$name" "$best")" = 1 ]; then + best="$name" + fi + done + printf '%s' "$best" +} + +package_version() { + sed -n -e 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*$/\1/p' "$1" 2>/dev/null | head -n 1 +} + +pid="$start_pid" +exe="" +target="" +found=0 +steps=0 +while [ "$steps" -lt 3 ]; do + case "$pid" in + '' | 0 | *[!0-9]*) break ;; + esac + exe="$(readlink -- "$PROC/$pid/exe" 2>/dev/null)" || exe="" + target="${exe% (deleted)}" + if [ -n "$target" ] && is_claude_binary "$target"; then + found=1 + break + fi + pid="$(parent_of "$pid")" || pid="" + steps=$((steps + 1)) +done +[ "$found" = 1 ] || exit 0 + +replaced=0 +[ "$exe" = "$target" ] || replaced=1 + +installed="" +from_path="" +case "$target" in + */claude/versions/*) + installed="$(highest_in "${target%/*}")" + from_path="${target##*/}" ;; + */node_modules/@anthropic-ai/*) + installed="$(package_version "${target%%/node_modules/@anthropic-ai/*}/node_modules/@anthropic-ai/claude-code/package.json")" + if [ "$replaced" = 0 ]; then + from_path="$(package_version "${target%/bin/*}/package.json")" + fi ;; +esac + +is_version "$running" || running="$from_path" +is_version "$running" || running="" +is_version "$installed" || installed="" + +newer=0 +if [ -n "$installed" ] && [ -n "$running" ] && [ "$(vercmp "$installed" "$running")" = 1 ]; then + newer=1 +fi +[ "$replaced" = 1 ] || [ "$newer" = 1 ] || exit 0 + +line="RESTART NEEDED: running ${running:-an older build}, installed ${installed:-a newer build}; /ctx at your next breakpoint" +if [ -n "${NO_COLOR:-}" ]; then + printf '%s\n' "$line" +else + printf '\033[32m%s\033[0m\n' "$line" +fi +exit 0 diff --git a/tests/test_claude_current.py b/tests/test_claude_current.py new file mode 100644 index 0000000..6ff129b --- /dev/null +++ b/tests/test_claude_current.py @@ -0,0 +1,551 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`claude-current`: the Claude Code a launch should start (opensoft/workBenches#119). + +EVERYTHING HERE IS FAKED, AND NOTHING REACHES A NETWORK OR A REAL INSTALL. +`$HOME` is a temporary directory; `npm` and `node` are scripts first on the +command's own seams (`CLAUDE_CURRENT_NPM`, `CLAUDE_CURRENT_NODE`); every +candidate is a small script that answers `--version`; the user npm prefix, +the native versions directory, the image's system candidates and the lock +directory all live under `tmp_path`. `CLAUDE_CURRENT_SYSTEM_CANDIDATES` is +always set, so the real `/usr/local/bin/claude` and `/usr/bin/claude` of the +machine running the suite are never read. + +The cases follow the issue's Ask 1-8: the published version with a bound, +candidates by ABSOLUTE PATH and never by PATH, the update when behind under a +lock, verify and hand back one path, refuse with one escape, offline is not a +refusal, say what launched. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import time +from pathlib import Path + +import pytest + +from conftest import REPO, WINDOWS_SKIP + +import test_repo_hygiene as hygiene + +CMD = REPO / "claude-current" +PACKAGE = "@anthropic-ai/claude-code" + +pytestmark = [pytest.mark.skipif(shutil.which("bash") is None, + reason="claude-current is a bash script"), + WINDOWS_SKIP] + +#: A candidate: answers `--version` with ` (Claude Code)`, logs every +#: other call, and, for a NATIVE install, answers `update` by writing the +#: version `$FAKE_NATIVE_UPDATE_TO` names beside itself. +FAKE_CLAUDE = r"""#!/usr/bin/env bash +v='@VERSION@' +printf '%s %s\n' "$0" "$*" >> "${FAKE_CLAUDE_LOG:-/dev/null}" +case "${1:-}" in + --version) printf '%s (Claude Code)\n' "$v" ;; + update) + [ "${FAKE_NATIVE_UPDATE_RC:-0}" = 0 ] || exit "$FAKE_NATIVE_UPDATE_RC" + to="${FAKE_NATIVE_UPDATE_TO:-}" + if [ -n "$to" ]; then + sed -e "s/^v='[^']*'/v='$to'/" "$0" > "$(dirname "$0")/$to" + chmod 755 "$(dirname "$0")/$to" + fi ;; +esac +""" + +#: npm: `view` answers `$FAKE_NPM_PUBLISHED` (or fails, or sleeps); `install -g +#: --prefix

… @` lays the package out the way npm does — the +#: launcher linked from `

/bin/claude` into `lib/node_modules` — and, with +#: `$FAKE_NPM_HOOK_NEEDED=1`, leaves the launcher a stub until the package's +#: `install.cjs` has run, which is npm 12's skipped native-binary hook. +FAKE_NPM = r"""#!/usr/bin/env bash +printf '%s\n' "$*" >> "${FAKE_NPM_LOG:-/dev/null}" +case "${1:-}" in + view) + [ -n "${FAKE_NPM_VIEW_SLEEP:-}" ] && sleep "$FAKE_NPM_VIEW_SLEEP" + [ "${FAKE_NPM_VIEW_RC:-0}" = 0 ] || exit "$FAKE_NPM_VIEW_RC" + printf '%s\n' "${FAKE_NPM_PUBLISHED:-}" ;; + install) + [ "${FAKE_NPM_INSTALL_RC:-0}" = 0 ] || { echo "npm ERR! fake failure" >&2; exit "$FAKE_NPM_INSTALL_RC"; } + prefix=""; spec="" + while [ $# -gt 0 ]; do + case "$1" in + --prefix) prefix="$2"; shift 2 ;; + @anthropic-ai/claude-code@*) spec="$1"; shift ;; + *) shift ;; + esac + done + v="${spec##*@}" + pkg="$prefix/lib/node_modules/@anthropic-ai/claude-code" + rm -rf "$pkg"; mkdir -p "$pkg/bin" "$prefix/bin" + printf '{\n "name": "@anthropic-ai/claude-code",\n "version": "%s"\n}\n' "$v" > "$pkg/package.json" + sed -e "s/@VERSION@/$v/" "$FAKE_CLAUDE_TEMPLATE" > "$pkg/claude.real" + chmod 755 "$pkg/claude.real" + if [ "${FAKE_NPM_HOOK_NEEDED:-0}" = 1 ]; then + printf '#!/usr/bin/env bash\necho "the native binary was never installed" >&2\nexit 1\n' > "$pkg/bin/claude.exe" + else + cp "$pkg/claude.real" "$pkg/bin/claude.exe" + fi + chmod 755 "$pkg/bin/claude.exe" + printf 'cp "%s" "%s"\n' "$pkg/claude.real" "$pkg/bin/claude.exe" > "$pkg/install.cjs" + ln -sfn ../lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe "$prefix/bin/claude" ;; +esac +""" + +#: node: runs the fake package's `install.cjs`, which is a shell line. +FAKE_NODE = r"""#!/usr/bin/env bash +printf '%s\n' "$*" >> "${FAKE_NODE_LOG:-/dev/null}" +bash "$1" +""" + + +class Sandbox: + """One hermetic world for one case.""" + + def __init__(self, root: Path) -> None: + self.root = root + self.home = root / "home" + self.bin = root / "bin" + self.prefix = root / "prefix" + self.native = self.home / ".local" / "share" / "claude" / "versions" + self.system = root / "system" + self.cache = root / "cache" + for d in (self.home, self.bin, self.system, self.cache): + d.mkdir(parents=True, exist_ok=True) + self.template = root / "fake-claude.template" + self.template.write_text(FAKE_CLAUDE) + self._script(self.bin / "npm", FAKE_NPM) + self._script(self.bin / "node", FAKE_NODE) + self.npm_log = root / "npm.log" + self.node_log = root / "node.log" + self.claude_log = root / "claude.log" + for log in (self.npm_log, self.node_log, self.claude_log): + log.write_text("") + self.system_candidates: list[Path] = [] + + @staticmethod + def _script(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + path.chmod(0o755) + return path + + def claude(self, path: Path, version: str) -> Path: + return self._script(path, FAKE_CLAUDE.replace("@VERSION@", version)) + + def native_version(self, version: str) -> Path: + return self.claude(self.native / version, version) + + def user_copy(self, version: str) -> Path: + """An npm install in the user prefix, laid out the way npm lays it out.""" + pkg = self.prefix / "lib" / "node_modules" / "@anthropic-ai" / "claude-code" + exe = self.claude(pkg / "bin" / "claude.exe", version) + (pkg / "package.json").write_text( + f'{{\n "name": "{PACKAGE}",\n "version": "{version}"\n}}\n') + link = self.prefix / "bin" / "claude" + link.parent.mkdir(parents=True, exist_ok=True) + if link.is_symlink() or link.exists(): + link.unlink() + link.symlink_to(Path("..") / "lib" / "node_modules" / "@anthropic-ai" + / "claude-code" / "bin" / "claude.exe") + assert exe.is_file() + return link + + def system_copy(self, name: str, version: str) -> Path: + path = self.claude(self.system / name / "claude", version) + self.system_candidates.append(path) + return path + + def env(self, **extra: str) -> dict: + env = {k: v for k, v in os.environ.items() + if not k.startswith(("CLAUDE_", "FAKE_", "NPM_CONFIG", "npm_config"))} + env.update({ + "HOME": str(self.home), + "PATH": f"{self.bin}{os.pathsep}{env.get('PATH', '/usr/bin:/bin')}", + "XDG_CACHE_HOME": str(self.root / "xdg-cache"), + "CLAUDE_CURRENT_NPM": str(self.bin / "npm"), + "CLAUDE_CURRENT_NODE": str(self.bin / "node"), + "CLAUDE_CURRENT_NPM_PREFIX": str(self.prefix), + "CLAUDE_CURRENT_CACHE_DIR": str(self.cache), + "CLAUDE_CURRENT_SYSTEM_CANDIDATES": ":".join(str(p) for p in self.system_candidates), + "FAKE_NPM_LOG": str(self.npm_log), + "FAKE_NODE_LOG": str(self.node_log), + "FAKE_CLAUDE_LOG": str(self.claude_log), + "FAKE_CLAUDE_TEMPLATE": str(self.template), + "FAKE_NPM_PUBLISHED": "2.1.284", + }) + env.update(extra) + return env + + def run(self, *args: str, timeout: float = 60, **extra: str) -> subprocess.CompletedProcess: + return subprocess.run([str(CMD), *args], env=self.env(**extra), + capture_output=True, text=True, timeout=timeout, + check=False) + + def installs(self) -> list[str]: + return [line for line in self.npm_log.read_text().splitlines() + if line.startswith("install")] + + +def porcelain(result: subprocess.CompletedProcess) -> dict: + fields = dict(line.split("=", 1) for line in result.stdout.splitlines()) + assert sorted(fields) == ["path", "published", "status", "version"], result.stdout + return fields + + +@pytest.fixture +def box(tmp_path: Path) -> Sandbox: + return Sandbox(tmp_path) + + +# --- Ask 4 and 7: verify, hand back one absolute path, say what launched ------ + +def test_a_current_user_copy_is_verified_and_printed_by_its_absolute_path(box): + link = box.user_copy("2.1.284") + result = box.run() + assert result.returncode == 0, result.stderr + assert result.stdout == f"{link}\n" + assert f"claude-current: claude 2.1.284 (verified against npm 2.1.284) at {link}" \ + in result.stderr + assert box.installs() == [], "a current copy must not be reinstalled" + + +def test_porcelain_is_four_lines_naming_path_version_published_and_status(box): + link = box.user_copy("2.1.284") + result = box.run("--porcelain") + assert result.returncode == 0, result.stderr + assert porcelain(result) == {"path": str(link), "version": "2.1.284", + "published": "2.1.284", "status": "verified"} + + +def test_the_published_version_is_asked_of_npm_fresh(box): + box.user_copy("2.1.284") + box.run() + views = [line for line in box.npm_log.read_text().splitlines() + if line.startswith("view")] + assert views == [f"view {PACKAGE} version --prefer-online"] + + +# --- Ask 2: candidates by absolute path, never through PATH ------------------ + +def test_a_claude_first_on_path_is_never_consulted(box): + """The py-bench case of 2026-09-29: two installs competed on PATH. The one + on PATH here is the NEWEST and is still never run, because a launch is + decided by absolute paths alone.""" + box.claude(box.bin / "claude", "9.9.9") + link = box.user_copy("2.1.284") + result = box.run("--porcelain") + assert porcelain(result)["path"] == str(link) + assert str(box.bin / "claude") not in box.claude_log.read_text() + + +def test_the_image_copy_behind_npm_loses_to_the_user_copy_that_equals_it(box): + box.system_copy("usr", "2.1.280") + link = box.user_copy("2.1.284") + result = box.run("--porcelain") + assert porcelain(result)["path"] == str(link) + + +def test_an_image_copy_that_equals_npm_is_launched_when_it_is_the_only_one(box): + image = box.system_copy("usr", "2.1.284") + result = box.run("--porcelain") + fields = porcelain(result) + assert (fields["path"], fields["status"]) == (str(image), "verified") + + +def test_an_equal_candidate_beats_a_newer_one(box): + box.native_version("2.1.285") + link = box.user_copy("2.1.284") + fields = porcelain(box.run("--porcelain")) + assert (fields["path"], fields["status"]) == (str(link), "verified") + + +def test_the_native_versions_are_ordered_numerically_not_by_name(box): + """workBenches #109's ordering: 2.1.10 is newer than 2.1.9.""" + box.native_version("2.1.9") + newest = box.native_version("2.1.10") + fields = porcelain(box.run("--porcelain", FAKE_NPM_PUBLISHED="2.1.10")) + assert (fields["path"], fields["status"]) == (str(newest), "verified") + + +def test_one_file_under_two_spellings_is_read_once(box): + """`~/.local/bin/claude` is the native installer's link to its version + file. It is ONE candidate, kept under its first spelling.""" + native = box.native_version("2.1.283") + local = box.home / ".local" / "bin" / "claude" + local.parent.mkdir(parents=True) + local.symlink_to(native) + result = box.run(FAKE_NPM_INSTALL_RC="1", FAKE_NATIVE_UPDATE_RC="1") + assert result.returncode == 2, result.stderr + assert f"Installed: 2.1.283 at {native}." in result.stderr + + +# --- Ask 3: update when behind, under a lock --------------------------------- + +def test_a_user_copy_behind_npm_is_updated_in_the_user_prefix_and_verified(box): + link = box.user_copy("2.1.283") + result = box.run("--porcelain") + assert result.returncode == 0, result.stderr + assert box.installs() == [ + f"install -g --prefix {box.prefix} --no-fund --no-audit {PACKAGE}@2.1.284"] + fields = porcelain(result) + assert fields == {"path": str(link), "version": "2.1.284", + "published": "2.1.284", "status": "verified"} + assert "updated" in result.stderr + + +def test_the_package_hook_npm_skipped_is_run_before_the_version_is_read(box): + box.user_copy("2.1.283") + result = box.run("--porcelain", FAKE_NPM_HOOK_NEEDED="1") + assert result.returncode == 0, result.stderr + hook = box.prefix / "lib" / "node_modules" / "@anthropic-ai" / "claude-code" / "install.cjs" + assert box.node_log.read_text().splitlines() == [str(hook)] + assert porcelain(result)["status"] == "verified" + + +def test_a_native_install_behind_npm_is_updated_with_claude_update(box): + box.native_version("2.1.283") + result = box.run("--porcelain", FAKE_NATIVE_UPDATE_TO="2.1.284") + assert result.returncode == 0, result.stderr + fields = porcelain(result) + assert (fields["path"], fields["status"]) == (str(box.native / "2.1.284"), "verified") + assert f"{box.native / '2.1.283'} update" in box.claude_log.read_text() + assert box.installs() == [], "the native update reached npm's version" + + +def test_a_native_update_that_stops_short_falls_back_to_the_user_prefix(box): + """A native install on another channel (npm's `stable` tag lags `latest`) + cannot reach the published version with `claude update`, so the user + prefix is installed instead.""" + box.native_version("2.1.283") + result = box.run("--porcelain", FAKE_NATIVE_UPDATE_TO="") + assert result.returncode == 0, result.stderr + assert len(box.installs()) == 1 + fields = porcelain(result) + assert (fields["path"], fields["status"]) == (str(box.prefix / "bin" / "claude"), "verified") + + +def test_nothing_installed_is_installed_into_the_user_prefix(box): + fields = porcelain(box.run("--porcelain")) + assert (fields["path"], fields["status"]) == (str(box.prefix / "bin" / "claude"), "verified") + + +def _hold_lock(box: Sandbox, seconds: int, then: str = ":") -> subprocess.Popen: + """Hold the update lock the way a second launch would, then run `then`.""" + lock = box.cache / "claude-current.lock" + if shutil.which("flock"): + script = f'exec 9>>"{lock}"; flock 9; touch "{box.root}/held"; sleep {seconds}; {then}' + else: + script = (f'mkdir "{lock}.d"; echo $$ > "{lock}.d/pid"; touch "{box.root}/held"; ' + f'sleep {seconds}; {then}; rm -rf "{lock}.d"') + proc = subprocess.Popen(["bash", "-c", script], env=box.env()) + deadline = time.monotonic() + 10 + while not (box.root / "held").exists(): + assert time.monotonic() < deadline, "the lock holder never took the lock" + time.sleep(0.05) + return proc + + +def test_a_launch_waits_for_another_launchs_update_and_does_not_repeat_it(box): + box.user_copy("2.1.283") + installer = (f'"{box.bin / "npm"}" install -g --prefix "{box.prefix}" ' + f'{PACKAGE}@2.1.284 >/dev/null 2>&1') + holder = _hold_lock(box, 2, then=installer) + try: + result = box.run("--porcelain") + finally: + holder.wait(timeout=30) + assert result.returncode == 0, result.stderr + assert porcelain(result)["status"] == "verified" + assert "updated by another launch" in result.stderr + assert len(box.installs()) == 1, "only the other launch installed" + + +def test_a_lock_that_is_never_free_is_a_refusal_that_names_it(box): + box.user_copy("2.1.283") + holder = _hold_lock(box, 8) + try: + result = box.run(CLAUDE_CURRENT_LOCK_WAIT="1") + finally: + holder.kill() + holder.wait(timeout=30) + assert result.returncode == 2, result.stderr + assert "was not free within 1s" in result.stderr + assert box.installs() == [] + + +# --- Ask 5: refuse, with one escape ------------------------------------------ + +def test_a_launch_still_behind_after_the_update_is_refused(box): + link = box.user_copy("2.1.283") + result = box.run(FAKE_NPM_INSTALL_RC="1") + assert result.returncode == 2 + assert result.stdout == "", "a refusal hands back no path to launch" + for want in ("REFUSED", "npm publishes claude 2.1.284", + f"the newest installed is 2.1.283 at {link}", + f"Fix: npm install -g --prefix {box.prefix} {PACKAGE}@2.1.284", + "CLAUDE_ALLOW_STALE=1 launches 2.1.283 anyway"): + assert want in result.stderr, want + + +def test_claude_allow_stale_launches_the_newest_and_says_it_is_stale(box): + link = box.user_copy("2.1.283") + result = box.run("--porcelain", FAKE_NPM_INSTALL_RC="1", CLAUDE_ALLOW_STALE="1") + assert result.returncode == 0, result.stderr + assert porcelain(result) == {"path": str(link), "version": "2.1.283", + "published": "2.1.284", "status": "stale"} + assert "STALE:" in result.stderr + + +def test_a_copy_ahead_of_npm_launches_as_ahead_and_nothing_is_installed(box): + link = box.user_copy("2.1.285") + result = box.run("--porcelain") + assert result.returncode == 0, result.stderr + fields = porcelain(result) + assert (fields["path"], fields["status"]) == (str(link), "ahead") + assert "ahead of npm 2.1.284" in result.stderr + assert box.installs() == [] + + +# --- Ask 6: offline is not a refusal ----------------------------------------- + +def test_npm_unreachable_launches_the_newest_installed_unverified(box): + box.native_version("2.1.280") + link = box.user_copy("2.1.283") + result = box.run("--porcelain", FAKE_NPM_VIEW_RC="1") + assert result.returncode == 0, result.stderr + assert porcelain(result) == {"path": str(link), "version": "2.1.283", + "published": "", "status": "unverified"} + assert "UNVERIFIED: could not reach npm" in result.stderr + assert box.installs() == [] + + +def test_npm_that_hangs_is_bounded_by_the_timeout(box): + box.user_copy("2.1.283") + started = time.monotonic() + result = box.run("--porcelain", FAKE_NPM_VIEW_SLEEP="30", CLAUDE_CURRENT_TIMEOUT="1") + assert time.monotonic() - started < 20, "npm view was not bounded" + assert result.returncode == 0, result.stderr + assert porcelain(result)["status"] == "unverified" + assert "took longer than 1s" in result.stderr + + +def test_offline_reads_no_npm_and_installs_nothing(box): + link = box.user_copy("2.1.283") + result = box.run("--porcelain", "--offline") + assert result.returncode == 0, result.stderr + assert porcelain(result)["path"] == str(link) + assert porcelain(result)["status"] == "unverified" + assert box.npm_log.read_text() == "" + + +def test_nothing_installed_and_npm_unreachable_is_exit_1(box): + result = box.run(FAKE_NPM_VIEW_RC="1") + assert result.returncode == 1 + assert result.stdout == "" + assert "no runnable Claude Code was found by absolute path" in result.stderr + + +def test_no_npm_at_all_is_unverified_not_a_crash(box): + link = box.user_copy("2.1.283") + result = box.run("--porcelain", CLAUDE_CURRENT_NPM=str(box.root / "no-such-npm")) + assert result.returncode == 0, result.stderr + assert porcelain(result)["path"] == str(link) + assert "there is no npm" in result.stderr + + +# --- the user prefix ---------------------------------------------------------- + +def test_the_user_prefix_falls_back_to_npm_config_prefix_then_to_npm_global(box): + elsewhere = box.root / "configured-prefix" + env = {"CLAUDE_CURRENT_NPM_PREFIX": ""} + result = box.run("--porcelain", NPM_CONFIG_PREFIX=str(elsewhere), **env) + assert porcelain(result)["path"] == str(elsewhere / "bin" / "claude") + result = box.run("--porcelain", **env) + assert porcelain(result)["path"] == str(box.home / ".npm-global" / "bin" / "claude") + + +def test_the_user_prefix_is_read_from_the_npmrc_prefix_line(box): + (box.home / ".npmrc").write_text("fund=false\nprefix = ~/tools/npm\n") + result = box.run("--porcelain", CLAUDE_CURRENT_NPM_PREFIX="") + assert porcelain(result)["path"] == str(box.home / "tools" / "npm" / "bin" / "claude") + + +# --- usage -------------------------------------------------------------------- + +def test_help_and_an_unknown_argument(box): + result = box.run("--help") + assert result.returncode == 0 + assert result.stdout.startswith("claude-current [--porcelain] [--offline]\n") + result = box.run("--bogus") + assert result.returncode == 64 + assert "unknown argument: --bogus" in result.stderr + + +@pytest.mark.parametrize("name", ["TIMEOUT", "VERSION_TIMEOUT", "UPDATE_TIMEOUT", "LOCK_WAIT"]) +def test_a_malformed_bound_is_refused_before_anything_runs(box, name): + result = box.run(**{f"CLAUDE_CURRENT_{name}": "soon"}) + assert result.returncode == 1 + assert f"CLAUDE_CURRENT_{name} must be a whole number" in result.stderr + assert box.npm_log.read_text() == "" + + +# --- the file itself ------------------------------------------------------------ + +#: The rules `tests/test_repo_hygiene.py` holds every listed bash file to, +#: asked of the two #119 commands, which its lists do not name yet (that file +#: is another open pull request's, opensoft/openRepoTools#93, so this one +#: calls its rules rather than editing its lists). +HYGIENE_RULES = [ + hygiene.test_shipped_bash_parses_under_bash, + hygiene.test_no_shipped_bash_ends_its_options_after_an_operand, + hygiene.test_no_shipped_bash_leaves_a_variable_name_to_bash_3_2s_locale, + hygiene.test_no_shipped_bash_quotes_the_replacement_half_of_a_substitution, + hygiene.test_no_shipped_bash_opens_a_case_inside_a_command_substitution, + hygiene.test_no_shipped_bash_reaches_for_gnu_only_utilities_unaccompanied, +] + + +@pytest.mark.parametrize("rule", HYGIENE_RULES, ids=lambda r: r.__name__) +@pytest.mark.parametrize("name", ["claude-current", "claude-restart-check"]) +def test_the_two_commands_keep_the_repositorys_bash_rules(name, rule): + rule(name) + + +def test_claude_current_fails_loudly_and_claude_restart_check_never_does(): + """`claude-current` is a launch's gate, so it stops at the first failure; + `claude-restart-check` runs inside every status line render, so it takes + `set -u` and never `set -e` — `lane-handoff`'s exception, for its reason.""" + hygiene.test_shipped_bash_is_executable_and_fails_loudly("claude-current") + hygiene.test_the_lane_helpers_are_executable_and_declare_their_discipline( + "claude-restart-check") + code = [line for line in + (REPO / "claude-restart-check").read_text(encoding="utf-8").splitlines() + if not line.lstrip().startswith("#")] + assert not [line for line in code if re.match(r"\s*set\s+-[a-z]*e", line)], ( + "claude-restart-check must not `set -e`: a failed read has to reach its " + "own 'print nothing, exit 0', not end the status line's render") + + +def test_the_macos_job_parses_both_commands_with_bash_3_2(): + workflow = (REPO / ".github" / "workflows" / "tests.yml").read_text(encoding="utf-8") + for name in ("claude-current", "claude-restart-check"): + assert f"/bin/bash -n {name}" in workflow, name + + +def _function(text: str, name: str) -> str: + match = re.search(rf"^{name}\(\) \{{\n.*?^\}}\n", text, re.S | re.M) + assert match, f"no {name}() in the file" + return match.group(0) + + +@pytest.mark.parametrize("name", ["numcmp", "vercmp"]) +def test_both_commands_order_versions_with_the_same_code(name): + """One idea of "newer" in both commands: a restart notice that disagreed + with the resolver about which version is newer would contradict it.""" + current = (REPO / "claude-current").read_text(encoding="utf-8") + check = (REPO / "claude-restart-check").read_text(encoding="utf-8") + assert _function(current, name) == _function(check, name) diff --git a/tests/test_claude_restart_check.py b/tests/test_claude_restart_check.py new file mode 100644 index 0000000..1a546c5 --- /dev/null +++ b/tests/test_claude_restart_check.py @@ -0,0 +1,204 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`claude-restart-check`: the RESTART NEEDED line for a running session +(opensoft/workBenches#119, the added scope of 2026-09-29). + +THE PROCESS TABLE IS A DIRECTORY THIS SUITE BUILDS. `CLAUDE_RESTART_CHECK_PROC` +points the command at a fake `/proc` under `tmp_path`: each process is a +directory holding a `status` file with its `PPid:` and an `exe` symlink whose +TEXT is the target the kernel would report, including the ` (deleted)` +suffix a replaced binary carries. Nothing here reads the real `/proc` or runs +a real Claude Code. + +The shape measured on py-bench, 2026-09-29, is the default fixture: the status +line command runs under `/bin/sh -c`, dash does not exec it, so its parent is +the shell and claude is the shell's parent. npm renames the old package +directory before deleting it, so the running exe path names a directory like +`.claude-code-h9B5EqFE` that no longer exists. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from conftest import REPO, WINDOWS_SKIP + +CMD = REPO / "claude-restart-check" +GREEN, RESET = "\033[32m", "\033[0m" + +pytestmark = [pytest.mark.skipif(shutil.which("bash") is None, + reason="claude-restart-check is a bash script"), + WINDOWS_SKIP] + + +class Proc: + """A fake process table plus the installs it points into.""" + + def __init__(self, root: Path) -> None: + self.root = root + self.proc = root / "proc" + self.proc.mkdir() + self.prefix = root / "npm-global" + self.versions = root / "home" / ".local" / "share" / "claude" / "versions" + + def process(self, pid: int, ppid: int, exe: str | None = None) -> None: + d = self.proc / str(pid) + d.mkdir() + (d / "status").write_text(f"Name:\tx\nPid:\t{pid}\nPPid:\t{ppid}\n") + if exe is not None: + (d / "exe").symlink_to(exe) + + def npm_package(self, version: str) -> Path: + pkg = self.prefix / "lib" / "node_modules" / "@anthropic-ai" / "claude-code" + (pkg / "bin").mkdir(parents=True, exist_ok=True) + (pkg / "package.json").write_text( + '{\n "name": "@anthropic-ai/claude-code",\n' + f' "version": "{version}",\n "bin": {{ "claude": "bin/claude.exe" }}\n}}\n') + exe = pkg / "bin" / "claude.exe" + exe.write_text("#!/bin/sh\n") + exe.chmod(0o755) + return exe + + def replaced_npm_exe(self) -> str: + """What the kernel reports after npm swapped the package underneath.""" + return str(self.prefix / "lib" / "node_modules" / "@anthropic-ai" + / ".claude-code-h9B5EqFE" / "bin" / "claude.exe") + " (deleted)" + + def native(self, *versions: str) -> None: + self.versions.mkdir(parents=True, exist_ok=True) + for v in versions: + f = self.versions / v + f.write_text("#!/bin/sh\n") + f.chmod(0o755) + + def run(self, *args: str, **env: str) -> subprocess.CompletedProcess: + full = {k: v for k, v in os.environ.items() if k != "NO_COLOR"} + full["CLAUDE_RESTART_CHECK_PROC"] = str(self.proc) + full.update(env) + return subprocess.run([str(CMD), *args], env=full, capture_output=True, + text=True, timeout=30, check=False) + + +@pytest.fixture +def table(tmp_path: Path) -> Proc: + return Proc(tmp_path) + + +def status_line_tree(t: Proc, claude_exe: str) -> None: + """claude (200) -> /bin/sh -c (100): the status line passes --pid 100.""" + t.process(200, 1, claude_exe) + t.process(100, 200, "/usr/bin/dash") + + +def restart(old: str, new: str) -> str: + return f"RESTART NEEDED: running {old}, installed {new}; /ctx at your next breakpoint" + + +def test_a_binary_npm_replaced_under_the_session_asks_for_a_restart(table): + table.npm_package("2.1.284") + status_line_tree(table, table.replaced_npm_exe()) + result = table.run("--running", "2.1.283", "--pid", "100", NO_COLOR="1") + assert result.returncode == 0, result.stderr + assert result.stdout == restart("2.1.283", "2.1.284") + "\n" + + +def test_the_line_is_green_unless_no_color_is_set(table): + table.npm_package("2.1.284") + status_line_tree(table, table.replaced_npm_exe()) + result = table.run("--running", "2.1.283", "--pid", "100") + assert result.stdout == f"{GREEN}{restart('2.1.283', '2.1.284')}{RESET}\n" + + +def test_a_current_session_prints_nothing(table): + exe = table.npm_package("2.1.284") + status_line_tree(table, str(exe)) + result = table.run("--running", "2.1.284", "--pid", "100") + assert (result.returncode, result.stdout) == (0, "") + + +def test_an_installed_version_newer_than_the_running_one_asks_even_undeleted(table): + exe = table.npm_package("2.1.284") + status_line_tree(table, str(exe)) + result = table.run("--running", "2.1.283", "--pid", "100", NO_COLOR="1") + assert result.stdout == restart("2.1.283", "2.1.284") + "\n" + + +def test_a_native_session_behind_the_newest_native_version_asks(table): + """The native updater writes the new version beside the old one, so the + running file is still there: the version comparison is what notices.""" + table.native("2.1.283", "2.1.284") + status_line_tree(table, str(table.versions / "2.1.283")) + result = table.run("--pid", "100", NO_COLOR="1") + assert result.stdout == restart("2.1.283", "2.1.284") + "\n" + + +def test_a_native_session_on_the_newest_version_prints_nothing(table): + table.native("2.1.9", "2.1.10") + status_line_tree(table, str(table.versions / "2.1.10")) + assert table.run("--pid", "100").stdout == "" + + +def test_a_running_version_that_is_not_a_version_falls_back_to_the_path(table): + table.native("2.1.283", "2.1.284") + status_line_tree(table, str(table.versions / "2.1.283")) + result = table.run("--running", "unknown", "--pid", "100", NO_COLOR="1") + assert result.stdout == restart("2.1.283", "2.1.284") + "\n" + + +def test_a_replaced_binary_with_no_readable_versions_still_asks(table): + status_line_tree(table, table.replaced_npm_exe()) + result = table.run("--pid", "100", NO_COLOR="1") + assert result.stdout == restart("an older build", "a newer build") + "\n" + + +def test_the_walk_reaches_claude_three_processes_up_and_no_further(table): + table.npm_package("2.1.284") + table.process(300, 1, table.replaced_npm_exe()) + table.process(200, 300, "/usr/bin/bash") + table.process(100, 200, "/usr/bin/dash") + found = table.run("--running", "2.1.283", "--pid", "100", NO_COLOR="1") + assert found.stdout == restart("2.1.283", "2.1.284") + "\n" + table.process(50, 100, "/usr/bin/bash") + too_far = table.run("--running", "2.1.283", "--pid", "50") + assert (too_far.returncode, too_far.stdout) == (0, "") + + +def test_the_walk_starts_at_the_parent_by_default(table): + """No --pid: the walk starts at this command's own parent, which for a + `subprocess.run` is the test process itself.""" + table.npm_package("2.1.284") + table.process(200, 1, table.replaced_npm_exe()) + table.process(os.getpid(), 200, "/usr/bin/python3") + result = table.run("--running", "2.1.283", NO_COLOR="1") + assert result.stdout == restart("2.1.283", "2.1.284") + "\n" + + +def test_no_claude_above_it_prints_nothing(table): + table.process(200, 1, "/usr/bin/tmux") + table.process(100, 200, "/usr/bin/dash") + assert table.run("--pid", "100").stdout == "" + + +def test_a_host_with_no_process_table_prints_nothing_and_exits_0(table): + result = table.run("--running", "2.1.283", "--pid", "100", + CLAUDE_RESTART_CHECK_PROC=str(table.root / "no-proc")) + assert (result.returncode, result.stdout, result.stderr) == (0, "", "") + + +def test_a_pid_that_is_not_a_number_prints_nothing_and_exits_0(table): + result = table.run("--pid", "not-a-pid") + assert (result.returncode, result.stdout) == (0, "") + + +def test_help_and_an_unknown_argument(table): + result = table.run("--help") + assert result.returncode == 0 + assert result.stdout.startswith("claude-restart-check [--running ] [--pid ]\n") + result = table.run("--bogus") + assert result.returncode == 64 + assert "unknown argument: --bogus" in result.stderr + assert table.run("--pid").returncode == 64 From 359c4601bf4db6b9766c88c32b7bce1f19cc2bc7 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:43:13 +0000 Subject: [PATCH 02/18] claude-current: the watchdog ends the whole tree, and one launch reaps a dead owner's lock Two Copilot findings on opensoft/openRepoTools#134, both in the fallbacks a stock macOS takes, and a third wording fault found beside them: - With no `timeout` and no `gtimeout`, the watchdog killed only the command. A child the command had started (npm's lifecycle processes, a script's `sleep`) kept the caller's command substitution open, so a 1 s bound took as long as the child did. `kill_tree` now stops, walks (`pgrep -P`), signals and continues the whole tree, as GNU `timeout` ends its process group, and the watchdog's own `sleep` is ended the same way. - With no `flock`, two launches that both read one dead owner each removed "the stale lock", and the second removal could take the lock the first had just made, so both updated at once. Only the launch holding the reaper directory (`.d.reap`) removes it now, and only while the lock still names the pid it saw dead. A reaper left by a launch killed in those lines blocks the takeover, and the refusal names it. - A lock directory that could not be created was reported as a lock that "was not free within 330s". It is named as what it is. tests/test_claude_current.py runs the two fallbacks on every host by hiding `timeout`, `gtimeout` or `flock` from PATH. Against the previous head the watchdog case takes the fake npm's full 30 s and the reaper case removes the lock and updates; both pass here. 52 cases. Refs opensoft/workBenches#119 Lane: openxfactory-5 (openXfactory-5) Co-Authored-By: Claude Opus 5.5 --- claude-current | 76 +++++++++++++++++++----- tests/test_claude_current.py | 112 +++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 14 deletions(-) diff --git a/claude-current b/claude-current index b506a04..638503b 100755 --- a/claude-current +++ b/claude-current @@ -3,6 +3,9 @@ # the newest version npm publishes, verified with `--version`, and it is named # by an ABSOLUTE PATH. `claude` is never resolved through PATH. # +# Installed on PATH by `openRepoTools --install`, beside `lane-start`, which +# calls it. +# # Why it exists (opensoft/workBenches#119, Brett Heap's rulings of 2026-09-29): # a lane started on an old Claude Code, and nothing noticed. Two installs # competed on PATH, the image's copy frozen at the image build and the user's @@ -166,10 +169,27 @@ is_version() { [[ "${1:-}" =~ $version_re ]]; } # ------------------------------------------------------------ small helpers +# kill_tree : the process and every process below it. Each one is stopped +# before its children are listed, so none of them can start another while the +# tree is walked, then sent TERM and continued so that it can act on it. +# `pgrep -P` is on Linux and macOS alike; without it only is signalled. +kill_tree() { + local child + kill -STOP "$1" 2>/dev/null || return 0 + for child in $(pgrep -P "$1" 2>/dev/null); do + kill_tree "$child" + done + kill -TERM "$1" 2>/dev/null || : + kill -CONT "$1" 2>/dev/null || : +} + # run_bounded [args...]: the command's own status, or 124 # when it ran out of time. GNU `timeout`, else Homebrew's `gtimeout`, else a -# watchdog, because a stock macOS ships neither. The watchdog's output goes to -# /dev/null so that it never holds a caller's command substitution open. +# watchdog, because a stock macOS ships neither. The watchdog ends the WHOLE +# tree, as `timeout` ends its process group: a command's own children (npm's +# lifecycle processes, a script's `sleep`) would otherwise keep the caller's +# command substitution open after the command itself was killed. The +# watchdog's own output goes to /dev/null for the same reason. run_bounded() { local secs="$1" pid dog rc=0 shift @@ -183,10 +203,10 @@ run_bounded() { fi "$@" & pid=$! - ( sleep "$secs"; kill -TERM "$pid" 2>/dev/null ) >/dev/null 2>&1 & + ( sleep "$secs"; kill_tree "$pid" ) >/dev/null 2>&1 & dog=$! wait "$pid" || rc=$? - kill "$dog" 2>/dev/null || : + kill_tree "$dog" wait "$dog" 2>/dev/null || : [ "$rc" = 143 ] && rc=124 return "$rc" @@ -393,14 +413,24 @@ EOF lock_fd_open=0 mkdir_lock="" +lock_why="" +# take_lock: 0 with the lock held, or 1 with lock_why saying why not. take_lock() { - local waited=0 d owner - mkdir -p -- "$CACHE_DIR" 2>/dev/null || return 1 + local waited=0 d owner reaped + if ! mkdir -p -- "$CACHE_DIR" 2>/dev/null; then + lock_why="the update lock's directory $CACHE_DIR could not be created" + return 1 + fi + lock_why="the update lock $LOCK_FILE was not free within ${LOCK_WAIT}s" if command -v flock >/dev/null 2>&1; then - exec 9>>"$LOCK_FILE" || return 1 + if ! exec 9>>"$LOCK_FILE"; then + lock_why="the update lock $LOCK_FILE could not be opened" + return 1 + fi lock_fd_open=1 if flock -w "$LOCK_WAIT" 9; then + lock_why="" return 0 fi exec 9>&- @@ -409,19 +439,40 @@ take_lock() { fi # No flock on a stock macOS: a mkdir lock whose owner writes its pid, so a # lock left by a process that died is taken over, not waited out. + # + # ONE LAUNCH REMOVES A DEAD OWNER'S LOCK, and it holds a second directory, + # the reaper, while it looks again. Two launches that both read the same + # dead pid would otherwise both remove "the stale lock", and the second + # removal would take the lock the first had just made, so both would + # update at once. Under the reaper the lock is removed only while it still + # names the pid that was seen dead. A reaper left by a launch killed in + # those few lines blocks the takeover, and the refusal names it. d="$LOCK_FILE.d" while ! mkdir -- "$d" 2>/dev/null; do owner="$(cat -- "$d/pid" 2>/dev/null || :)" - if [ -n "$owner" ] && ! kill -0 "$owner" 2>/dev/null; then - rm -rf -- "$d" - continue + if [ -n "$owner" ] && ! kill -0 "$owner" 2>/dev/null && + mkdir -- "$d.reap" 2>/dev/null; then + reaped=0 + if [ "$(cat -- "$d/pid" 2>/dev/null || :)" = "$owner" ] && + rm -rf -- "$d" 2>/dev/null && [ ! -e "$d" ]; then + reaped=1 + fi + rmdir -- "$d.reap" 2>/dev/null || : + # Taken over: try for the lock at once. Not taken over (another + # launch got there first, or the lock would not go): wait as usual. + [ "$reaped" = 1 ] && continue + fi + if [ "$waited" -ge "$LOCK_WAIT" ]; then + [ -d "$d.reap" ] && + lock_why="$lock_why, and $d.reap, the directory a launch holds while it removes a dead owner's lock, is still there: remove it if no launch is running" + return 1 fi - [ "$waited" -lt "$LOCK_WAIT" ] || return 1 sleep 1 waited=$((waited + 1)) done printf '%s\n' "$$" >"$d/pid" mkdir_lock="$d" + lock_why="" return 0 } @@ -544,11 +595,8 @@ fi # Every candidate is behind, or there is none. Update under the lock, and read # again after taking it: another launch may have updated while this one waited. locked=0 -lock_why="" if take_lock; then locked=1 -else - lock_why="the update lock $LOCK_FILE was not free within ${LOCK_WAIT}s" fi collect if pick equal "$PUBLISHED"; then diff --git a/tests/test_claude_current.py b/tests/test_claude_current.py index 6ff129b..97ce505 100644 --- a/tests/test_claude_current.py +++ b/tests/test_claude_current.py @@ -377,6 +377,20 @@ def test_a_lock_that_is_never_free_is_a_refusal_that_names_it(box): assert box.installs() == [] +def test_a_lock_directory_that_cannot_be_made_is_named_as_that(box): + """Not as a lock somebody else holds: nobody does, and waiting would not + help. A regular file where a parent directory should be refuses the + `mkdir -p` whoever runs it, root included.""" + box.user_copy("2.1.283") + blocker = box.root / "not-a-directory" + blocker.write_text("", encoding="utf-8") + result = box.run(CLAUDE_CURRENT_CACHE_DIR=str(blocker / "cache")) + assert result.returncode == 2, result.stderr + assert f"the update lock's directory {blocker / 'cache'} could not be created" in result.stderr + assert "was not free" not in result.stderr + assert box.installs() == [] + + # --- Ask 5: refuse, with one escape ------------------------------------------ def test_a_launch_still_behind_after_the_update_is_refused(box): @@ -433,6 +447,104 @@ def test_npm_that_hangs_is_bounded_by_the_timeout(box): assert "took longer than 1s" in result.stderr +def path_without(box: Sandbox, *names: str) -> str: + """The sandbox's PATH, with every command the host's PATH finds except + `names`: how a stock macOS looks to this command (no `timeout`, no + `gtimeout`, no `flock`) on any host, so the fallbacks run everywhere.""" + shadow = box.root / ("path-without-" + "-".join(names)) + shadow.mkdir() + for directory in os.environ.get("PATH", "").split(os.pathsep): + if not directory or not os.path.isdir(directory): + continue + try: + entries = list(os.scandir(directory)) + except OSError: + continue + for entry in entries: + target = shadow / entry.name + if entry.name in names or target.exists() or target.is_symlink(): + continue + try: + runnable = entry.is_file() and os.access(entry.path, os.X_OK) + except OSError: + continue + if runnable: + target.symlink_to(entry.path) + for name in names: + assert not (shadow / name).exists() + return f"{box.bin}{os.pathsep}{shadow}" + + +def test_the_watchdog_ends_a_hung_commands_children_too(box): + """Copilot on #134: with no `timeout` and no `gtimeout` the watchdog killed + only the command, and the command's own child (here the fake npm's + `sleep 30`) kept the command substitution open for all thirty seconds. + The watchdog now ends the whole tree, as `timeout` ends its group.""" + box.user_copy("2.1.283") + started = time.monotonic() + result = box.run("--porcelain", FAKE_NPM_VIEW_SLEEP="30", CLAUDE_CURRENT_TIMEOUT="1", + PATH=path_without(box, "timeout", "gtimeout")) + elapsed = time.monotonic() - started + assert elapsed < 15, f"the watchdog did not end the tree: {elapsed:.1f}s" + assert result.returncode == 0, result.stderr + assert porcelain(result)["status"] == "unverified" + assert "npm view took longer than 1s" in result.stderr + + +def _dead_pid() -> int: + proc = subprocess.Popen(["true"]) + proc.wait() + return proc.pid + + +def test_a_mkdir_lock_whose_owner_died_is_taken_over(box): + """No `flock`: the lock is a directory holding its owner's pid, and a pid + that is no longer running is a lock nobody holds.""" + box.user_copy("2.1.283") + lock = box.cache / "claude-current.lock.d" + lock.mkdir() + (lock / "pid").write_text(f"{_dead_pid()}\n") + result = box.run("--porcelain", CLAUDE_CURRENT_LOCK_WAIT="5", + PATH=path_without(box, "flock")) + assert result.returncode == 0, result.stderr + assert porcelain(result)["status"] == "verified" + assert len(box.installs()) == 1 + assert not lock.exists(), "the lock was not released" + assert not (box.cache / "claude-current.lock.d.reap").exists() + + +def test_a_dead_owners_lock_another_launch_is_reaping_is_left_to_it(box): + """Copilot on #134: two launches that both saw one dead owner each removed + "the stale lock", and the second removal took the first one's new lock. + Only the launch holding the reaper directory removes it, so a launch that + finds the reaper taken leaves the lock alone and waits.""" + box.user_copy("2.1.283") + lock = box.cache / "claude-current.lock.d" + lock.mkdir() + (lock / "pid").write_text(f"{_dead_pid()}\n") + reaper = box.cache / "claude-current.lock.d.reap" + reaper.mkdir() + result = box.run(CLAUDE_CURRENT_LOCK_WAIT="1", PATH=path_without(box, "flock")) + assert result.returncode == 2, result.stderr + assert lock.is_dir(), "a launch removed a lock another launch was reaping" + assert "was not free within 1s" in result.stderr + assert f"{reaper}, the directory a launch holds while it removes a dead owner's lock" in result.stderr + assert box.installs() == [] + + +def test_a_live_mkdir_lock_is_waited_on_and_never_removed(box): + box.user_copy("2.1.283") + lock = box.cache / "claude-current.lock.d" + lock.mkdir() + (lock / "pid").write_text(f"{os.getpid()}\n") + result = box.run(CLAUDE_CURRENT_LOCK_WAIT="1", PATH=path_without(box, "flock")) + assert result.returncode == 2, result.stderr + assert (lock / "pid").read_text() == f"{os.getpid()}\n" + assert "was not free within 1s" in result.stderr + assert "the directory a launch holds" not in result.stderr + assert box.installs() == [] + + def test_offline_reads_no_npm_and_installs_nothing(box): link = box.user_copy("2.1.283") result = box.run("--porcelain", "--offline") From 3c33ec48874c089cce60c0022c6dfb11fc2a7b28 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:43:13 +0000 Subject: [PATCH 03/18] lane-start: an unset CLAUDE_BIN launches what claude-current names, and a stale one is refused before any write opensoft/workBenches#119, Brett Heap 2026-09-29, verbatim "for 2, we can run update on every start, this ensures we have the latest models". The hook is minimal and touches three places only: - Beside the CLAUDE_BIN default: CLAUDE_VERIFIED_VERSION is read once and removed, so no session inherits it. CLAUDE_BIN unset, or equal to CLAUDE_RESOLVED_BIN with no CLAUDE_VERIFIED_VERSION (an earlier launch's answer inherited through a session's environment), is resolved. Any other CLAUDE_BIN is a pin, launched as it is. - At the foot of 5a, once the agent and the launch are known and before the row, the object log and the handoff stamp are written: `claude-current --porcelain`, found beside this command or in $OPENREPOTOOLS_BIN_DIR and never on PATH. Exit 0 replaces the command's first word with the absolute path and exports CLAUDE_BIN and CLAUDE_RESOLVED_BIN; exit 2 ends the run with 2 and anything else with 1, nothing written. `--dry-run` plans the call and `--no-launch` makes none. Only the `claude` agent asks. With no claude-current installed, one note, and CLAUDE_BIN as before. - The lane's STARTED/RESUMED payload gains `; claude ` when a version is in hand, and only when it is x.y.z. Step 4 is untouched. The bare path a `--confirm` answered No takes still launches CLAUDE_BIN unresolved. tests/test_lane_start_claude_current.py: 17 cases in a cut-down copy of the lane suite's sandbox, with a stub resolver and a decoy one on PATH. Refs opensoft/workBenches#119 Lane: openxfactory-5 (openXfactory-5) Co-Authored-By: Claude Opus 5.5 --- lane-start | 121 ++++++- tests/test_lane_start_claude_current.py | 420 ++++++++++++++++++++++++ 2 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 tests/test_lane_start_claude_current.py diff --git a/lane-start b/lane-start index 3a24831..952c120 100755 --- a/lane-start +++ b/lane-start @@ -252,7 +252,25 @@ # ~/.local/bin) — searched for lanes-edit.sh # CLAUDE_CONFIG_DIR the profile whose sessions/ and projects/ are read # CLAUDE_PROJECTS_DIR default $CLAUDE_CONFIG_DIR/projects, else ~/.claude/projects -# CLAUDE_BIN default `claude` from PATH +# CLAUDE_BIN the Claude Code to launch. Unset, it is the absolute +# path `claude-current --porcelain` answers, the one +# beside this command or in $OPENREPOTOOLS_BIN_DIR and +# never one on PATH: npm's published version, updated +# first when every copy is behind, and a stale one +# REFUSED with exit 2 before anything is written +# (opensoft/workBenches#119). `claude` from PATH only +# where no claude-current is installed. Set, it is a pin +# and launched as it is, unless it equals +# CLAUDE_RESOLVED_BIN with no CLAUDE_VERIFIED_VERSION: +# that is an earlier launch's answer inherited through a +# session's environment, and it is resolved again +# CLAUDE_RESOLVED_BIN the path a resolution chose, exported beside CLAUDE_BIN +# CLAUDE_VERIFIED_VERSION the version a launcher's own claude-current read +# for CLAUDE_BIN (equal to CLAUDE_RESOLVED_BIN): recorded +# as `claude ` on the log line, and removed +# before the launch so no session inherits it +# CLAUDE_ALLOW_STALE=1 claude-current answers with a stale copy instead of +# refusing it; docs/README-claude-current.md has the rest # LANE_START_SESSION_ID 0 turns off minting a uuid for a new session (and # therefore turns off writing it into the row) # LANE_START_ANSWER answers `--confirm`'s question without a terminal — @@ -560,8 +578,48 @@ LANES_EDIT="${LANES_EDIT:-$(lanes_edit_default)}" PROJECTS_ROOT="${PROJECTS_ROOT:-$HOME/projects}" CLAUDE_HOME="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" PROJECTS_STATE="${CLAUDE_PROJECTS_DIR:-$CLAUDE_HOME/projects}" + +# WHICH CLAUDE CODE A LAUNCH STARTS (opensoft/workBenches#119, Brett Heap +# 2026-09-29, verbatim "for 2, we can run update on every start, this ensures +# we have the latest models"). Decided here, from the environment as it +# arrived, and acted on at the foot of 5a, once the agent and the launch are +# known. Three shapes, and docs/README-claude-current.md has the contract: +# * CLAUDE_BIN unset: resolve through `claude-current`, the one `--install` +# places beside this command; +# * CLAUDE_BIN equal to CLAUDE_RESOLVED_BIN with no CLAUDE_VERIFIED_VERSION: +# an earlier launch's answer, inherited through a session's environment. +# It is not an operator's pin, so it is resolved again; +# * anything else is a pin and is launched as it is. A pin that a launcher's +# own `claude-current` produced carries CLAUDE_VERIFIED_VERSION, which is +# recorded on the log line as `claude `. +# CLAUDE_VERIFIED_VERSION is read once and removed, so it never reaches the +# session: a launch from inside that session must resolve again. +lane_claude_version="" +lane_claude_resolve=0 +if [ -z "${CLAUDE_BIN:-}" ]; then + lane_claude_resolve=1 +elif [ "$CLAUDE_BIN" = "${CLAUDE_RESOLVED_BIN:-}" ]; then + if [ -n "${CLAUDE_VERIFIED_VERSION:-}" ]; then + lane_claude_version="$CLAUDE_VERIFIED_VERSION" + else + lane_claude_resolve=1 + fi +fi +unset CLAUDE_VERIFIED_VERSION CLAUDE_BIN="${CLAUDE_BIN:-claude}" +# `claude-current` is looked for where `--install` puts it and NEVER on PATH: +# beside this command, else in $OPENREPOTOOLS_BIN_DIR. A sandbox that copies +# this command somewhere without it, as the lane suite does, then launches the +# `claude` its own PATH names, and no test reaches a real npm. +lane_claude_current() { + local d + [ -x "$SCRIPT_DIR/claude-current" ] && { printf '%s\n' "$SCRIPT_DIR/claude-current"; return 0; } + d="${OPENREPOTOOLS_BIN_DIR:-$HOME/.local/bin}" + [ -x "$d/claude-current" ] && { printf '%s\n' "$d/claude-current"; return 0; } + return 1 +} + LS_TMP="" cleanup() { [ -n "${LS_TMP:-}" ] && rm -f -- "$LS_TMP"; return 0; } trap cleanup EXIT INT TERM @@ -2595,6 +2653,54 @@ else fi cmd+=(${pass[@]+"${pass[@]}"}) +# THE CLAUDE CODE TO START, AND A STALE ONE IS REFUSED HERE, BEFORE ANY WRITE +# (opensoft/workBenches#119). `claude-current --porcelain` reads npm's published +# version, updates the user-writable install when every copy is behind, and +# answers with an absolute path it has run `--version` on. Its exit 2 is a +# refusal (every copy still behind npm) and its 1 an environment with nothing +# runnable; either ends this run with the same status, before the row, the log +# and the handoff are written, which is where the launcher table's own PATH +# check sits for the same reason. Its stderr is the operator's to read and goes +# straight through. Never under `--no-launch`, which launches nothing and is +# the first act INSIDE a running session, and never under `--dry-run`, which +# must not update anything. +if [ "$AGENT" = claude ] && (( lane_claude_resolve )) && (( ! no_launch )); then + if ! lcc_cmd="$(lane_claude_current)"; then + note "no claude-current beside this command or in ${OPENREPOTOOLS_BIN_DIR:-$HOME/.local/bin}, so $CLAUDE_BIN is launched as it is (through PATH, where it is a bare name), unchecked against npm; openRepoTools --install places claude-current" + elif (( dry_run )); then + plan "$lcc_cmd --porcelain (chooses the Claude Code to launch; not run under --dry-run, because it may update one)" + else + lcc_rc=0 + lcc_out="$("$lcc_cmd" --porcelain pins one." 2 ;; + *) die "claude-current found no Claude Code to launch (exit $lcc_rc, its reason above). Nothing was written: the row, the object log and the handoff's Rule 3 stamp all come after this." 1 ;; + esac + lcc_path=""; lcc_version=""; lcc_status="" + while IFS= read -r lcc_line; do + case "$lcc_line" in + path=*) [ -n "$lcc_path" ] || lcc_path="${lcc_line#path=}" ;; + version=*) [ -n "$lcc_version" ] || lcc_version="${lcc_line#version=}" ;; + status=*) [ -n "$lcc_status" ] || lcc_status="${lcc_line#status=}" ;; + esac + done <` and the `/ctx` seam — @@ -3012,6 +3118,19 @@ esac lane_payload="home $home_nwo; estate $estate; dir $(quote_subfield "$DIR")" [ -n "$lane_profile" ] && lane_payload="$lane_payload; profile $(quote_subfield "$lane_profile")" [ -n "$record_window" ] && lane_payload="$lane_payload; window $record_window" +# `claude `: the Claude Code this launch starts, as claude-current read +# it here or as a launcher's own claude-current handed it on +# (opensoft/workBenches#119). In hand only then, so appended only then, for +# the reason given above; and a version is digits, dots and a tag, so a value +# that is not one is left out rather than written into a line nothing rewrites. +lane_claude_version_re='^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.+-]+)?$' +if [ "$AGENT" = claude ] && [ -n "$lane_claude_version" ]; then + if [[ "$lane_claude_version" =~ $lane_claude_version_re ]]; then + lane_payload="$lane_payload; claude $lane_claude_version" + else + note "the Claude Code version '$lane_claude_version' is not x.y.z, so the lane's log line carries no \`claude\` sub-field" + fi +fi # AMENDMENT 8(d), R-A8-2 — `unknown` NEVER REACHES THE OBJECT LOG, AND THE RULE # IS THE CONDITION RATHER THAN THE PATH. Amendment 7(b): the `session` field is diff --git a/tests/test_lane_start_claude_current.py b/tests/test_lane_start_claude_current.py new file mode 100644 index 0000000..8dea251 --- /dev/null +++ b/tests/test_lane_start_claude_current.py @@ -0,0 +1,420 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`lane-start` and `claude-current`: which Claude Code a lane launches +(opensoft/workBenches#119, Brett Heap 2026-09-29, verbatim "for 2, we can run +update on every start, this ensures we have the latest models"). + +EVERY RUN HERE IS A REAL `lane-start` IN A SANDBOX, and the resolver is a +STUB. The sandbox is the lane suite's, cut down to one new lane: a bare origin +and a workspace clone whose register is a header, `workspace.yaml` pointing at +it, a fake `tmux` that answers the reads a launch makes, a fake `gh` that +refuses, and `$HOME`, `$TMPDIR` and `$OPENREPOTOOLS_BIN_DIR` all under +`tmp_path`. `claude-current` itself is `tests/test_claude_current.py`'s; the +stub here answers what a case needs and logs how it was called, so nothing +reaches npm or runs a real Claude Code. + +The contract under test is docs/README-claude-current.md's: + * CLAUDE_BIN unset: `claude-current --porcelain`, found beside `lane-start` + or in $OPENREPOTOOLS_BIN_DIR and NEVER on PATH, names the launch by an + absolute path; its exit 2 refuses the launch with 2 and its 1 with 1, + before the register is written; + * CLAUDE_BIN equal to CLAUDE_RESOLVED_BIN with no CLAUDE_VERIFIED_VERSION is + inherited, not pinned, and is resolved again; + * any other CLAUDE_BIN is a pin, launched as it is; with + CLAUDE_VERIFIED_VERSION beside an equal CLAUDE_RESOLVED_BIN its version is + recorded; + * the log line carries `claude `, and CLAUDE_VERIFIED_VERSION never + reaches the session. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from conftest import REPO, WINDOWS_SKIP + +pytestmark = [pytest.mark.skipif(shutil.which("bash") is None, + reason="lane-start is a bash script"), + pytest.mark.skipif(shutil.which("git") is None, + reason="the register is a git repository"), + WINDOWS_SKIP] + +LANE = "repoZ-1" +VERSION = "2.1.284" + +#: The environment a case must not inherit from the host that runs it: every +#: seam `lane-start`, `lanes-edit.sh` and `claude-current` read. +SCRUBBED_PREFIXES = ("LANES_", "LANE_START_", "CLAUDE_", "OPENREPOTOOLS_", + "FAKE_", "GIT_", "NPM_CONFIG_", "npm_config_", "TMUX") +SCRUBBED = {"AGENT_PROTOCOL_ROOT", "PROJECTS_ROOT", "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", "PCLAUDE"} + +FAKE_TMUX = r"""#!/usr/bin/env bash +# The lane suite's fake tmux, cut down: this window is testsess:@1, index 0, +# named `claude`, and every other ref resolves nothing. rename-window and +# send-keys are logged; everything else answers nothing and succeeds. +case "${1-}" in + display-message) + shift + t=""; f="" + while [ $# -gt 0 ]; do + case "$1" in + -p) shift ;; + -t) t="${2-}"; shift 2 ;; + *) f="$1"; shift ;; + esac + done + if [ -n "$t" ]; then + case "$t" in + testsess:0|testsess:@1|@1) : ;; + *) exit 1 ;; + esac + case "$f" in + '#{window_id}') echo '@1' ;; + '#{window_name}') echo 'claude' ;; + '#{session_name}') echo 'testsess' ;; + '#{pane_current_command}') echo 'claude' ;; + *) echo ;; + esac + exit 0 + fi + case "$f" in + '#{session_name}:#{window_id}') echo 'testsess:@1' ;; + '#{session_name}:#{window_index}') echo 'testsess:0' ;; + '#S:#I') echo 'testsess:0' ;; + '#{window_id}') echo '@1' ;; + '#W'|'#{window_name}') echo 'claude' ;; + *) echo ;; + esac ;; + rename-window) + printf 'rename-window %s\n' "${2-}" >> "$FAKE_TMUX_LOG" ;; + send-keys) + shift + printf 'send-keys %s\n' "$*" >> "$FAKE_TMUX_LOG" ;; + *) : ;; +esac +""" + +FAKE_GH = """#!/usr/bin/env bash +printf 'FAKE gh REFUSED: %s -- this suite reaches no GitHub surface\\n' "$*" >&2 +exit 90 +""" + +#: A Claude Code that records how it was started and what it inherited. One +#: script, copied to every path a case launches, so the log says which one ran. +FAKE_CLAUDE = """#!/usr/bin/env bash +{ + printf 'ran %s: %s\\n' "$0" "$*" + printf 'env CLAUDE_BIN=%s\\n' "${CLAUDE_BIN-}" + printf 'env CLAUDE_RESOLVED_BIN=%s\\n' "${CLAUDE_RESOLVED_BIN-}" + printf 'env CLAUDE_VERIFIED_VERSION=%s\\n' "${CLAUDE_VERIFIED_VERSION-}" +} >> "$FAKE_CLAUDE_LOG" +""" + +#: The resolver stub: logs its arguments, then answers from FAKE_CC_* the way +#: `claude-current --porcelain` does. +FAKE_CLAUDE_CURRENT = """#!/usr/bin/env bash +printf '%s %s\\n' "$0" "$*" >> "$FAKE_CC_LOG" +if [ -n "${FAKE_CC_STDERR:-}" ]; then printf '%s\\n' "$FAKE_CC_STDERR" >&2; fi +if [ -n "${FAKE_CC_RAW:-}" ]; then printf '%s\\n' "$FAKE_CC_RAW"; exit "${FAKE_CC_RC:-0}"; fi +if [ "${FAKE_CC_RC:-0}" = 0 ]; then + printf 'path=%s\\nversion=%s\\npublished=%s\\nstatus=%s\\n' \\ + "$FAKE_CC_PATH" "${FAKE_CC_VERSION-2.1.284}" "${FAKE_CC_PUBLISHED-2.1.284}" \\ + "${FAKE_CC_STATUS:-verified}" +fi +exit "${FAKE_CC_RC:-0}" +""" + + +def _write(path: Path, text: str, mode: int = 0o755) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + path.chmod(mode) + return path + + +class Sandbox: + """One workstation with one register and one lane directory, repoZ.""" + + def __init__(self, root: Path) -> None: + self.root = root + self.home = root / "home" + self.bin = root / "bin" + self.fakebin = root / "fakebin" + self.origin = root / "origin.git" + self.wip = self.home / "projects" / "wip" + self.lane_dir = self.home / "projects" / "repoZ" + self.claude_log = root / "claude.log" + self.cc_log = root / "claude-current.log" + self.tmux_log = root / "tmux.log" + for d in (self.home / "projects", root / "tmp", self.fakebin, self.bin, + self.home / ".claude" / "sessions", self.home / ".agents"): + d.mkdir(parents=True, exist_ok=True) + for f in (self.claude_log, self.cc_log, self.tmux_log): + f.write_text("", encoding="utf-8") + + base = {k: v for k, v in os.environ.items() + if k not in SCRUBBED and not k.startswith(SCRUBBED_PREFIXES)} + base.update({ + "HOME": str(self.home), + "TMPDIR": str(root / "tmp"), + "XDG_CONFIG_HOME": str(self.home / ".config"), + "XDG_CACHE_HOME": str(self.home / ".cache"), + "PATH": f"{self.fakebin}{os.pathsep}{self.bin}{os.pathsep}{base.get('PATH', '')}", + "TMUX": f"{root}/fake-tmux-socket,0,0", + "AGENT_PROTOCOL_ROOT": str(self.home / ".agents"), + "OPENREPOTOOLS_BIN_DIR": str(self.bin), + "CLAUDE_CONFIG_DIR": str(self.home / ".claude"), + "LANES_WORKSTATION": "Eagle", + "LANES_NO_GITHUB": "1", + "FAKE_TMUX_LOG": str(self.tmux_log), + "FAKE_CLAUDE_LOG": str(self.claude_log), + "FAKE_CC_LOG": str(self.cc_log), + "GIT_AUTHOR_NAME": "lane-start claude-current tests", + "GIT_AUTHOR_EMAIL": "test@example.invalid", + "GIT_COMMITTER_NAME": "lane-start claude-current tests", + "GIT_COMMITTER_EMAIL": "test@example.invalid", + }) + self.env = base + + for name in ("lane-start", "lanes-edit.sh", "repos.tsv"): + shutil.copy2(REPO / name, self.bin / name) + (self.bin / name).chmod(0o755) + _write(self.fakebin / "tmux", FAKE_TMUX) + _write(self.fakebin / "gh", FAKE_GH) + self.path_claude = _write(self.fakebin / "claude", FAKE_CLAUDE) + # A resolver on PATH that no case may ever reach. + _write(self.fakebin / "claude-current", + '#!/usr/bin/env bash\nprintf "PATH-DECOY %s\\n" "$*" >> "$FAKE_CC_LOG"\nexit 3\n') + self.resolved = _write(root / "resolved" / "bin" / "claude", FAKE_CLAUDE) + + self.git("init", "-q", "--bare", "-b", "main", str(self.origin)) + self.git("clone", "-q", str(self.origin), str(self.wip)) + (self.wip / "lanes").mkdir() + (self.wip / "handoffs").mkdir() + (self.wip / "handoffs" / "README.md").write_text( + "# handoffs\n", encoding="utf-8") + (self.wip / "lanes" / "LANES.md").write_text( + "# LANES.md -- the sandbox register\n\n" + "| lane | session id | workstation / env / user | started (UTC) " + "| objects owned | handoff path | state |\n" + "|---|---|---|---|---|---|---|\n", encoding="utf-8") + self.git("-C", str(self.wip), "add", "--", "lanes/LANES.md", + "handoffs/README.md") + self.git("-C", str(self.wip), "commit", "-q", "-m", "seed") + self.git("-C", str(self.wip), "push", "-q", "origin", "main") + (self.home / ".agents" / "workspace.yaml").write_text( + f"repository: {self.origin}\npath: {self.wip}\n", encoding="utf-8") + + self.git("init", "-q", "-b", "main", str(self.lane_dir)) + self.git("-C", str(self.lane_dir), "remote", "add", "origin", + "https://github.com/opensoft/repoZ.git") + + def git(self, *args: str) -> str: + return subprocess.run(["git", *args], env=self.env, check=True, + capture_output=True, text=True).stdout + + def resolver(self, where: Path | None = None) -> Path: + return _write((where or self.bin) / "claude-current", FAKE_CLAUDE_CURRENT) + + def start(self, *args: str, **env: str) -> subprocess.CompletedProcess: + full = dict(self.env) + full.setdefault("FAKE_CC_PATH", str(self.resolved)) + full.update(env) + return subprocess.run([str(self.bin / "lane-start"), "repoZ", "1", *args], + env=full, cwd=str(self.lane_dir), + capture_output=True, text=True, timeout=120, + check=False) + + def claude_runs(self) -> str: + return self.claude_log.read_text(encoding="utf-8") + + def resolver_calls(self) -> str: + return self.cc_log.read_text(encoding="utf-8") + + def lane_log(self) -> str: + path = self.wip / "lanes" / "log" / f"{LANE}.md" + return path.read_text(encoding="utf-8") if path.exists() else "" + + def commits(self) -> int: + return int(self.git("-C", str(self.wip), "rev-list", "--count", + "HEAD").strip()) + + +@pytest.fixture +def box(tmp_path: Path) -> Sandbox: + return Sandbox(tmp_path) + + +def _started_line(box: Sandbox) -> str: + lines = [ln for ln in box.lane_log().splitlines() if ln.startswith("STARTED")] + assert len(lines) == 1, box.lane_log() + return lines[0] + + +def test_an_unset_claude_bin_launches_what_claude_current_names(box): + stub = box.resolver() + result = box.start() + assert result.returncode == 0, result.stderr + assert box.resolver_calls() == f"{stub} --porcelain\n" + runs = box.claude_runs() + assert runs.startswith(f"ran {box.resolved}: --name {LANE} --session-id "), runs + assert f"ran {box.path_claude}" not in runs + assert f"env CLAUDE_BIN={box.resolved}\n" in runs + assert f"env CLAUDE_RESOLVED_BIN={box.resolved}\n" in runs + assert "env CLAUDE_VERIFIED_VERSION=\n" in runs + assert f"launching claude {VERSION} (verified) at {box.resolved}" in result.stderr + + +def test_the_log_line_records_the_version_claude_current_read(box): + box.resolver() + result = box.start() + assert result.returncode == 0, result.stderr + line = _started_line(box) + assert line.endswith(f"; claude {VERSION}"), line + assert "lane:repoZ-1 → home opensoft/repoZ; " in line, line + + +def test_a_refusal_ends_the_run_with_2_before_anything_is_written(box): + box.resolver() + before = box.commits() + refused = ("claude-current: REFUSED: npm publishes claude 2.1.284 and the " + "newest installed is 2.1.283") + result = box.start(FAKE_CC_RC="2", FAKE_CC_STDERR=refused) + assert result.returncode == 2, result.stderr + assert refused in result.stderr + assert "claude-current refused the launch (exit 2" in result.stderr + assert "Nothing was written" in result.stderr + assert box.claude_runs() == "" + assert box.commits() == before + assert box.lane_log() == "" + assert "repoZ-1" not in (box.wip / "lanes" / "LANES.md").read_text(encoding="utf-8") + + +@pytest.mark.parametrize("fake", [ + {"FAKE_CC_RC": "1"}, + {"FAKE_CC_RAW": "not porcelain"}, + {"FAKE_CC_PATH": "relative/claude"}, + {"FAKE_CC_PATH": "/nonexistent/claude-current-test/claude"}, +]) +def test_a_resolver_that_names_nothing_runnable_ends_the_run_with_1(box, fake): + box.resolver() + before = box.commits() + result = box.start(**fake) + assert result.returncode == 1, result.stderr + assert "Nothing was written" in result.stderr + assert box.claude_runs() == "" + assert box.commits() == before + + +def test_a_launchers_verified_version_is_trusted_recorded_and_removed(box): + """claude-profile ran its own claude-current and hands on all three.""" + box.resolver() + result = box.start(CLAUDE_BIN=str(box.resolved), + CLAUDE_RESOLVED_BIN=str(box.resolved), + CLAUDE_VERIFIED_VERSION="2.1.290") + assert result.returncode == 0, result.stderr + assert box.resolver_calls() == "" + runs = box.claude_runs() + assert runs.startswith(f"ran {box.resolved}: --name {LANE}"), runs + assert "env CLAUDE_VERIFIED_VERSION=\n" in runs + assert _started_line(box).endswith("; claude 2.1.290") + + +def test_an_operator_pin_is_launched_as_it_is_and_records_no_version(box): + box.resolver() + pinned = _write(box.root / "pinned" / "claude", FAKE_CLAUDE) + result = box.start(CLAUDE_BIN=str(pinned)) + assert result.returncode == 0, result.stderr + assert box.resolver_calls() == "" + assert box.claude_runs().startswith(f"ran {pinned}: --name {LANE}") + assert "; claude " not in _started_line(box) + + +def test_a_version_beside_a_different_resolved_path_is_not_the_pins(box): + """CLAUDE_VERIFIED_VERSION describes CLAUDE_RESOLVED_BIN. A CLAUDE_BIN + that is some other path is a pin, and that version is not its version.""" + box.resolver() + pinned = _write(box.root / "pinned" / "claude", FAKE_CLAUDE) + result = box.start(CLAUDE_BIN=str(pinned), + CLAUDE_RESOLVED_BIN=str(box.resolved), + CLAUDE_VERIFIED_VERSION="2.1.290") + assert result.returncode == 0, result.stderr + assert box.resolver_calls() == "" + assert "env CLAUDE_VERIFIED_VERSION=\n" in box.claude_runs() + assert "; claude " not in _started_line(box) + + +def test_an_inherited_resolution_is_resolved_again(box): + """A session's own environment carries CLAUDE_BIN and CLAUDE_RESOLVED_BIN + from the launch that started it. A launch from inside it is not pinned.""" + stub = box.resolver() + old = _write(box.root / "old" / "claude", FAKE_CLAUDE) + result = box.start(CLAUDE_BIN=str(old), CLAUDE_RESOLVED_BIN=str(old)) + assert result.returncode == 0, result.stderr + assert box.resolver_calls() == f"{stub} --porcelain\n" + assert box.claude_runs().startswith(f"ran {box.resolved}: --name {LANE}") + assert _started_line(box).endswith(f"; claude {VERSION}") + + +def test_dry_run_plans_the_resolution_and_runs_nothing(box): + stub = box.resolver() + before = box.commits() + result = box.start("--dry-run") + assert result.returncode == 0, result.stderr + assert f"lane-start: PLAN {stub} --porcelain" in result.stderr + assert box.resolver_calls() == "" + assert box.claude_runs() == "" + assert box.commits() == before + + +def test_no_launch_resolves_nothing(box): + """The first act inside a running session: nothing is launched, so there + is nothing to choose, and nothing may be updated under the session.""" + box.resolver() + result = box.start("--no-launch") + assert result.returncode == 0, result.stderr + assert result.stdout.startswith(f"claude --name {LANE}"), result.stdout + assert box.resolver_calls() == "" + assert box.claude_runs() == "" + + +def test_no_resolver_beside_it_launches_the_path_claude_and_says_so(box): + """The lane suite's shape: `lane-start` copied without claude-current. + The decoy on PATH proves the resolver is never looked for there.""" + result = box.start() + assert result.returncode == 0, result.stderr + assert box.resolver_calls() == "" + assert box.claude_runs().startswith(f"ran {box.path_claude}: --name {LANE}") + assert "no claude-current beside this command" in result.stderr + assert "; claude " not in _started_line(box) + + +def test_the_resolver_is_found_in_the_bin_dir_when_not_beside_it(box): + elsewhere = box.root / "installed" + stub = box.resolver(elsewhere) + result = box.start(OPENREPOTOOLS_BIN_DIR=str(elsewhere)) + assert result.returncode == 0, result.stderr + assert box.resolver_calls() == f"{stub} --porcelain\n" + assert box.claude_runs().startswith(f"ran {box.resolved}: --name {LANE}") + + +def test_a_version_that_is_not_one_is_left_off_the_log_line(box): + box.resolver() + result = box.start(FAKE_CC_VERSION="2.1; rm") + assert result.returncode == 0, result.stderr + assert "is not x.y.z" in result.stderr + assert "; claude " not in _started_line(box) + + +def test_another_agent_never_asks_claude_current(box): + box.resolver() + _write(box.fakebin / "codex", FAKE_CLAUDE) + result = box.start("--agent", "codex") + assert result.returncode == 0, result.stderr + assert box.resolver_calls() == "" + assert box.claude_runs().startswith(f"ran {box.fakebin / 'codex'}: ") From ea1dcb569aa6d9e62c6a98721b45dc8789fdc479 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:43:13 +0000 Subject: [PATCH 04/18] --install places claude-current and claude-restart-check, and their manual `INSTALLABLES` gains the two commands at its END, so every index the suite takes still names the file it did: FIFTEEN files and TWENTY-NINE artifacts, with the usage text, the header and every in-body count moved with them (`THIRTEEN SINCE AMENDMENT 16` and "the thirteen placements" stay: one is history and the other a different count). The home ruling of 2026-09-29 is why they are here: Brett Heap, verbatim "this work is really for openRepoTools repo". docs/README-claude-current.md is the manual: the candidates and their order, the update and its lock, output, exit codes and variables, the hand-off between launchers (CLAUDE_BIN, CLAUDE_RESOLVED_BIN, CLAUDE_VERIFIED_VERSION), what `lane-start` does with it, and the restart check the status line calls. README.md stays at 472 lines, the cap tests/test_repo_hygiene.py holds it to: the counts are rewritten in place, and "THIRTEEN files" and `13 of 13 placed`, which that test also asserts, survive as the history sentence that says when they stopped being true. The doc is linked from the existing line that names docs/README-lanes.md. Refs opensoft/workBenches#119 Lane: openxfactory-5 (openXfactory-5) Co-Authored-By: Claude Opus 5.5 --- README.md | 20 +-- claude-restart-check | 3 + docs/README-claude-current.md | 237 +++++++++++++++++++++++++++ openRepoTools | 75 +++++---- tests/test_install_skill_and_hook.py | 28 ++-- tests/test_openrepotools_command.py | 25 +-- 6 files changed, 326 insertions(+), 62 deletions(-) create mode 100644 docs/README-claude-current.md diff --git a/README.md b/README.md index 4256a24..249d195 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ and 5 put it and where it stays. What moved here is the CODE. A `-wip` is a private repository whose own rules file says "No secrets and no code", nobody would look inside somebody else's for a command, and until this move the four helpers existed in exactly one place in the world: one person's private -repository. `docs/README-lanes.md` is the manual. +repository. `docs/README-lanes.md` is the manual; `docs/README-claude-current.md` is the one for the Claude Code `lane-start` launches and the RESTART NEEDED line. Every one of them finds that data through `$AGENT_PROTOCOL_ROOT/workspace.yaml` — `repository:` and `path:`, the same pointer file `resume` and `status` @@ -235,14 +235,14 @@ gh api repos/opensoft/openRepoTools/contents/openRepoTools \ -H 'Accept: application/vnd.github.raw' | bash -s -- --install ``` -It places THIRTEEN files into `~/.local/bin` — `openRepoTools`, `park`, `resume`, +It places FIFTEEN files into `~/.local/bin` — `openRepoTools`, `park`, `resume`, `status`, `lane`, `lanes`, `lane-handoff`, `lane-rename`, `lanes-edit.sh`, -`lane-start`, `lane-end`, `link-estates` and the alias table `repos.tsv` — 755, -idempotently: a +`lane-start`, `lane-end`, `link-estates`, the alias table `repos.tsv`, +`claude-current` and `claude-restart-check` — 755, idempotently: a second run prints `already installed … (unchanged)` per file, one whose bytes have drifted prints `updated at`, and one whose bytes were right and whose MODE was not prints `(mode restored to 755)`: the mode is stamped on every artifact on every -run, whether or not the bytes moved. ALL THIRTEEN ARE IN HAND BEFORE ANY IS +run, whether or not the bytes moved. ALL FIFTEEN ARE IN HAND BEFORE ANY IS PLACED, so a fetch that failed replaces nothing and names the file it could not get. A mode stamp that FAILS is a refusal naming the file it could not mode (exit 2), never the shell's own 1: every `chmod` this command performs goes @@ -257,7 +257,7 @@ THAT EXISTS, so a bin directory that is not there yet is refused for the parent that would not take it, and a create needs a directory's search bit as well as its write bit. A refusal creates none of those directories either. `cp` follows a symlink, and an install through one leaves the command uninstalled and writes -these bytes into whatever it points at. Then a `13 of 13 placed in

` line, +these bytes into whatever it points at. Then a `15 of 15 placed in ` line, and the `export PATH=…` line if that directory is not on your `PATH`. It also places **fourteen things that are not files in that directory**: THREE @@ -286,7 +286,7 @@ changes no byte of them. An entry that runs string — a second writer of one of these hooks — a file it cannot parse, or a `hooks` that is not an object → it **refuses, prints the exact block, and places nothing at all**, because both merges are computed -with the thirteen files in hand before either is placed. An installer that +with the fifteen files in hand before either is placed. An installer that repairs a file it does not understand is how you lose a setting you meant. A SECOND WRITER REFUSES WHETHER OR NOT OUR OWN ENTRY IS BESIDE IT: the read asks for a rival BEFORE it asks whether ours is already there, so a file carrying @@ -297,7 +297,7 @@ own `UserPromptSubmit` hooks are left exactly where they are, which is why that arm keys on the VERB and not on the word anywhere in a path. It never writes a profile's own `settings.json`: the launcher owns that one. -Twenty-seven artifacts, and the count is the invariant. It was sixteen until A11 +Twenty-nine artifacts, and the count is the invariant. It was sixteen until A11 Addendum 4 ruling 9 gave `--install` a command-file list and `commands/swap.md` in it, at the same pair of paths a skill takes — because `opensoft/workBenches#74` deletes the launcher's copy and `/swap` would otherwise be installed by nobody; @@ -311,7 +311,7 @@ respawned with a new session whose first prompt is that handoff's top block. Twenty-six until **Amendment 16** (ratified the same day) put `lane-rename` on `PATH`: a lane is renamed by one word, in one commit — the row, the object log, the handoff and `lanes/aliases.tsv` — and its old name resolves for ever -afterwards, in every reader that takes a lane name. +afterwards, in every reader that takes a lane name. Twenty-seven, with THIRTEEN files and a `13 of 13 placed` line, until opensoft/workBenches#119 put `claude-current` and `claude-restart-check` on `PATH` (Brett Heap, 2026-09-29, verbatim *"this work is really for openRepoTools repo"*): the Claude Code a launch starts, and the RESTART NEEDED line the status line prints. Run from a checkout it copies the files beside it and needs no network and no `gh` at all; run from stdin, as above, it fetches all of them at the same ref. @@ -322,7 +322,7 @@ where `raw.githubusercontent.com` is blocked. |---|---|---| | `$OPENREPOTOOLS_REPO` | `opensoft/openRepoTools` | the `owner/name` to fetch from — a fork or a mirror, named once | | `$OPENREPOTOOLS_REF` | `main` | the ref to fetch it at | -| `$OPENREPOTOOLS_BIN_DIR` | `~/.local/bin` | where `--install` puts the thirteen | +| `$OPENREPOTOOLS_BIN_DIR` | `~/.local/bin` | where `--install` puts the fifteen, and where `lane-start` looks for `claude-current` | | `$AGENT_PROTOCOL_ROOT` | `~/.agents` | where `workspace.yaml` lives — the one pointer to your data | | `$CLAUDE_PROFILES_HOME` | `~/.claude-profiles` | the profiles root `--install` places the shared skills under | | `$LANES_WORKSTATION` | — | this workstation's name, exported by the workBenches launcher. Outside a container it defaults to `hostname -s`; **inside one with no value every writer refuses**, because a container id is not a workstation and the log is never rewritten (Amendment 11, decision 8(d)) | diff --git a/claude-restart-check b/claude-restart-check index dc2e613..e00f4e7 100755 --- a/claude-restart-check +++ b/claude-restart-check @@ -3,6 +3,9 @@ # under was started from a binary that has since been replaced on disk, or # runs an older version than the one installed. Nothing otherwise. # +# Installed on PATH by `openRepoTools --install`, for workBenches' shared +# status line to call. +# # Why it exists (opensoft/workBenches#119, Brett Heap's added scope of # 2026-09-29): a running session keeps the binary it loaded. When a launch's # update or the CLI's own auto-updater replaces that binary, every session diff --git a/docs/README-claude-current.md b/docs/README-claude-current.md new file mode 100644 index 0000000..8fd2fdb --- /dev/null +++ b/docs/README-claude-current.md @@ -0,0 +1,237 @@ +# `claude-current` and `claude-restart-check` + +Two commands `openRepoTools --install` places in `~/.local/bin` beside +`lane-start`, since opensoft/workBenches#119: + +- **`claude-current`** answers which Claude Code a launch should start: the + version npm publishes, updated first when every installed copy is behind, + checked with `--version`, and named by an absolute path. `lane-start` calls + it, and so does workBenches' `claude-profile`. +- **`claude-restart-check`** prints one green `RESTART NEEDED` line when a + running session's Claude Code has been replaced on disk. workBenches' shared + status line prints that line. + +Brett Heap ruled on #119 on 2026-09-29. The home ruling, verbatim *"this work +is really for openRepoTools repo"*, puts both commands here. Point 2, verbatim +*"for 2, we can run update on every start, this ensures we have the latest +models"*, is why every launch runs the check. The workBenches side is the +ratified OpenSpec change `launch-current-claude` (opensoft/workBenches#120). + +## Why + +A lane started on an old Claude Code, and nothing noticed. Two npm installs +competed on `PATH`: the image's root-owned copy, frozen when the image was +built, and the user's own `~/.npm-global` copy. Which one a lane got depended +on the shell that launched it. A session loads the served model catalog when it +starts, so a session that ran 2.1.283 for twenty hours did not list a model +that a fresh `claude` on the same account listed. The same session kept running +the binary npm had replaced under it, and nothing told it to restart. + +## `claude-current` + +```sh +claude-current # the absolute path, one line on stdout +claude-current --porcelain # path=, version=, published=, status= +claude-current --offline # no npm read and no update: newest installed +``` + +It never resolves `claude` through `PATH`. It reads these candidates by +absolute path, in this order, and runs each one's `--version`: + +1. the newest native version in `~/.local/share/claude/versions`, by its + x.y.z name (workBenches #109's ordering); +2. `/bin/claude`. The prefix is `$CLAUDE_CURRENT_NPM_PREFIX`, + else `$NPM_CONFIG_PREFIX`, else `$npm_config_prefix`, else the `prefix=` + line of `~/.npmrc`, else `~/.npm-global`; +3. `~/.local/bin/claude`; +4. the image's copies, `/usr/local/bin/claude` and `/usr/bin/claude` + (`$CLAUDE_CURRENT_SYSTEM_CANDIDATES`, colon-separated; set it empty for + none). + +A path that resolves to a file already read is read once, under its first +spelling, which is the user-writable one. + +It then asks npm, bounded by `$CLAUDE_CURRENT_TIMEOUT` seconds: +`npm view @anthropic-ai/claude-code version --prefer-online`. + +| What it finds | What it answers | `status` | +|---|---|---| +| a candidate at npm's version | the first such candidate | `verified` | +| none equal, one newer than npm | the newest newer one | `ahead` | +| npm could not be read | the newest candidate, and says `UNVERIFIED` | `unverified` | +| every candidate behind npm, or none installed | it updates, then reads again (below) | | +| still behind after the update | exit 2, a refusal naming the fix | | +| still behind, `CLAUDE_ALLOW_STALE=1` | the newest candidate, and says `STALE` | `stale` | + +An equal candidate beats a newer one. `ahead` is a launch, not a refusal. + +**The update runs under a lock** at +`${XDG_CACHE_HOME:-~/.cache}/openrepotools/claude-current.lock`: `flock` where +it exists, else a directory lock that records its owner's pid, so a lock left +by a process that died is taken over, by one launch only: the one that holds +a second directory, the reaper, while it reads the pid again. After it takes +the lock it reads the candidates again, because another launch may have +updated while it waited. A lock it cannot take within +`CLAUDE_CURRENT_LOCK_WAIT` seconds is a refusal, unless that other launch's +update has already produced npm's version. A native install gets +`claude update`. When that does not reach npm's version, +or there is no native install, it runs +`npm install -g --prefix @anthropic-ai/claude-code@`. +If `--version` still differs after that, it runs the package's own +`install.cjs` with `node`. npm 12's install-script policy skips that hook, so +until it runs the launcher npm links can be a stub. The shared image runs the +same hook by hand. Everything an update prints goes to stderr. + +**Output.** stdout carries the path, or with `--porcelain` exactly four lines: + +```text +path= +version= +published= +status= +``` + +stderr carries one line saying what was chosen, for example +`claude-current: claude 2.1.284 (verified against npm 2.1.284) at `. +A refusal prints nothing on stdout. + +**Exit.** 0 resolved; 1 no runnable Claude Code at all, or a malformed +setting; 2 refused, because every candidate is still behind npm after the +update; 64 usage. + +| Variable | Default | What it is | +|---|---|---| +| `CLAUDE_ALLOW_STALE` | unset | `1` answers with a stale copy instead of refusing, and says `STALE` | +| `CLAUDE_CURRENT_NPM` | `npm` | the npm to run | +| `CLAUDE_CURRENT_NODE` | `node` | the node that runs `install.cjs` | +| `CLAUDE_CURRENT_TIMEOUT` | `10` | seconds for `npm view` | +| `CLAUDE_CURRENT_VERSION_TIMEOUT` | `10` | seconds for each `--version` | +| `CLAUDE_CURRENT_UPDATE_TIMEOUT` | `300` | seconds for each update command | +| `CLAUDE_CURRENT_LOCK_WAIT` | `330` | seconds to wait for another launch's update | +| `CLAUDE_CURRENT_CACHE_DIR` | `${XDG_CACHE_HOME:-~/.cache}/openrepotools` | where the lock lives | +| `CLAUDE_CURRENT_NPM_PREFIX` | see candidate 2 | the user npm prefix, the one place an update writes | +| `CLAUDE_CURRENT_NATIVE_DIR` | `~/.local/share/claude/versions` | the native versions directory | +| `CLAUDE_CURRENT_SYSTEM_CANDIDATES` | `/usr/local/bin/claude:/usr/bin/claude` | the image's copies | + +The timeouts use `timeout`, else Homebrew's `gtimeout`, else a watchdog of its +own, because a stock macOS ships neither. The watchdog ends the command's whole +process tree, as `timeout` ends its process group, so a child npm started +cannot hold the answer open past the bound. + +## The hand-off + +A launcher that starts the path `claude-current` answered exports three +variables to what it launches next: + +- `CLAUDE_BIN=` +- `CLAUDE_RESOLVED_BIN=` +- `CLAUDE_VERIFIED_VERSION=` + +`CLAUDE_VERIFIED_VERSION` never reaches a running session. `lane-start` and +`claude-profile` both remove it before they start one. `CLAUDE_BIN` and +`CLAUDE_RESOLVED_BIN` may reach it, and that is what lets a later launch tell +an inherited answer from an operator's pin: + +| The environment a launch finds | What it is | What the launch does | +|---|---|---| +| no `CLAUDE_BIN` | nothing chosen yet | resolves through `claude-current` | +| `CLAUDE_BIN` = `CLAUDE_RESOLVED_BIN`, no `CLAUDE_VERIFIED_VERSION` | an earlier launch's answer, inherited from a session's environment | resolves again | +| `CLAUDE_BIN` = `CLAUDE_RESOLVED_BIN` and `CLAUDE_VERIFIED_VERSION` | a launcher's answer, handed on | trusts it, and records the version | +| any other `CLAUDE_BIN` | an operator's pin | launches it as it is, with no check | + +## `lane-start` + +`lane-start` applies that table at the end of step 5a, once it knows the agent +and the launch, and before step 5 writes anything: + +- It looks for `claude-current` beside itself, then in + `$OPENREPOTOOLS_BIN_DIR` (default `~/.local/bin`), and never on `PATH`. A + sandbox that copies `lane-start` without it, as the lane suite does, launches + the `claude` its own `PATH` names, and no test reaches a real npm. +- It runs `claude-current --porcelain` with stdin from `/dev/null`. The + resolver's stderr goes straight to the operator. +- Exit 0: the launch command's first word becomes the absolute path. + `CLAUDE_BIN` and `CLAUDE_RESOLVED_BIN` are exported with that path, and one + line says `launching claude () at `. +- Exit 2 ends `lane-start` with 2 and exit 1 with 1. Either way the row, the + object log and the handoff's Rule 3 stamp are not written. The window has + already been renamed for the lane by step 4, as it has for the launcher + table's own check on the agent's command. +- With no `claude-current` installed, one note says so, and `CLAUDE_BIN` + (`claude` from `PATH` when unset) is launched unchecked, as before. +- `--dry-run` prints a `PLAN` line naming the call and runs nothing, because + the resolver may update. `--no-launch` resolves nothing: it is the first act + inside a running session and launches nothing. +- Only the `claude` agent asks. `--agent codex` launches `codex` as before. + +The lane's `STARTED` or `RESUMED` log line records the version as one more +sub-field, `claude `, when a version is in hand: read by +`claude-current` in this run, or handed on as `CLAUDE_VERIFIED_VERSION`. For +example: + +```text +STARTED — lane repoZ-1, session @Eagle, , lane:repoZ-1 → home opensoft/repoZ; estate repoZ; dir ; window testsess:0 @1; claude 2.1.284 +``` + +A pinned launch records no version, and neither does a line written by the +deferred `--no-launch` act. A value that is not x.y.z is left off with a note, +because the log is append-only. + +One path is not covered yet. A `--confirm` answered No launches +`$CLAUDE_BIN` bare, without a lane, and it does not ask `claude-current`: an +unset `CLAUDE_BIN` there is still `claude` from `PATH`. Through +`claude-profile`, which hands on its own resolved path, that launch is +current anyway. + +## `claude-restart-check` + +```sh +claude-restart-check [--running ] [--pid ] +``` + +It walks at most three processes, from `--pid` (default: its own parent) up +through their parents, to the first one whose `/proc//exe` is a Claude +Code binary: a native version file (`…/claude/versions/`), or the npm +package's `…/node_modules/@anthropic-ai//bin/claude.exe`. The status line +runs its command under `/bin/sh -c`, and dash does not `exec` it, so claude is +the status line script's grandparent. That was measured on a live bench on +2026-09-29. + +It warns when either of these holds: + +- **The binary was replaced**: that exe link ends in ` (deleted)`. npm renames + the old package directory before deleting it, so the running exe's path + names a directory that no longer exists, and it cannot give the running + version. +- **The installed version is newer than the running one.** The installed + version is the highest x.y.z name in the native versions directory, or the + `version` in the npm package's `package.json` at its canonical path. The + running version is `--running` (the status line JSON's `version`), else the + native file's own name. + +The warning is exactly one line, green unless `NO_COLOR` is set: + +```text +RESTART NEEDED: running 2.1.283, installed 2.1.284; /ctx at your next breakpoint +``` + +It never acts. There is no kill and no automatic `/ctx`, because a working +session is never interrupted. It reads `/proc` and two small files, reaches no +network and runs no binary, so the render is not slowed by a `claude +--version`. It always exits 0, on a host with no `/proc` such as macOS too, +where it prints nothing: a status line must never break on this. 64 is kept +for an argument it does not know. `CLAUDE_RESTART_CHECK_PROC` (default +`/proc`) is the test seam. + +The status line that calls it is workBenches' +`base-image/files/claude-statusline-command.sh`, which +`scripts/setup-claude-profiles.sh` installs as the profiles' shared +`statusline-command.sh`. + +## Portability + +Both commands parse and run under bash 3.2, which is what macOS ships. Neither +uses `mapfile`, associative arrays, `readlink -f` or GNU-only flags. +`tests/test_claude_current.py`, `tests/test_claude_restart_check.py` and +`tests/test_lane_start_claude_current.py` are their suites, and every one of +them runs against fakes. None reaches npm or runs a real Claude Code. diff --git a/openRepoTools b/openRepoTools index d7f07ab..dca95f8 100755 --- a/openRepoTools +++ b/openRepoTools @@ -25,7 +25,7 @@ # change (Amendment 9(b), and R-A9-1). The identity that survives is narrower # and truer. # -# `--install` PLACES THIRTEEN FILES, not four: this file, `park`, `resume`, +# `--install` PLACES FIFTEEN FILES, not four: this file, `park`, `resume`, # `status` (openRepoShape #82; the fourth under Brett Heap's RULING of # 2026-09-10 in this repository, "lets go with a fourth file"), `lane` and # `lanes` (lane-collision-protocol Amendment 18 Addendum 1 and Amendment 11's @@ -38,13 +38,16 @@ # the alias table `repos.tsv` they read — which came here from # `opensoft/brett-wip` with their history under Amendment 9(b), because a # person's workspace repository holds their DATA and the code belongs where code -# goes. It also places THREE skills, `/handoff`, `/lane-swap` and `/restart`, at +# goes — and `claude-current` and `claude-restart-check` +# (opensoft/workBenches#119, Brett Heap's home ruling of 2026-09-29: the Claude +# Code a launch starts, and the RESTART NEEDED line a running session shows). It +# also places THREE skills, `/handoff`, `/lane-swap` and `/restart`, at # two paths each, THREE command files, `/handoff`, `/ctx` and `/swap`, at that # same pair of paths (A11 Addendum 4 ruling 9; Amendment 17(a) and (f)), and # merges TWO hook entries into # `~/.claude/settings.json` — the `SessionStart` one (Amendment 9(b), A8 # Addendum 2 R-A8-5) and, since lane-collision-protocol Amendment 12 adoption -# act 3, the `UserPromptSubmit` NAME GUARD beside it: TWENTY-SEVEN +# act 3, the `UserPromptSubmit` NAME GUARD beside it: TWENTY-NINE # artifacts, and the count is the invariant. # # One install line for the whole toolset, so a second engineer gets the estate @@ -72,7 +75,7 @@ die() { printf '\nREFUSED: %s\n' "$1" >&2; exit "${2:-2}"; } usage() { cat <<'USAGE' -openRepoTools --install install (or update) the thirteen estate and +openRepoTools --install install (or update) the fifteen estate and lane commands into ~/.local/bin, plus the /handoff, /lane-swap and /restart skills, the /handoff, /ctx and /swap command files, the @@ -83,9 +86,10 @@ openRepoTools wip init create your workspace repository, clone it, openRepoTools --help | --version `openRepoShape` is the standard's front door and scaffolds product -repositories; this command scaffolds none. `--install` places thirteen files +repositories; this command scaffolds none. `--install` places fifteen files beside each other — this command, the two verbs, the read-only view, the four -lane words, the four lane helpers and the alias table they read: +lane words, the four lane helpers, the alias table they read and the two +Claude Code launch commands: park [] commit, push and RECORD an estate's open features, so another workstation can take @@ -125,6 +129,13 @@ lane words, the four lane helpers and the alias table they read: uses, into your workspace repository repos.tsv the organisation's alias table, which `lanes-edit.sh` reads + claude-current [--porcelain] the Claude Code a launch starts: npm's + published version, updated when behind and + verified with --version, by absolute path + and never through PATH (lane-start uses it) + claude-restart-check one RESTART NEEDED line for a running + session whose binary moved on disk, for the + shared status line to print `--install` also places THREE SKILLS at two paths each — `~/.claude-profiles/shared/skills//SKILL.md` and @@ -223,7 +234,7 @@ fetch_from_repo() { return 1 } -# THE THIRTEEN FILES `--install` PLACES. One list, so the installer, the fetch +# THE FIFTEEN FILES `--install` PLACES. One list, so the installer, the fetch # fallback and the tests cannot disagree about what a complete install is. # `openRepoTools` is first because it is the one a person types to get the # others. @@ -265,7 +276,14 @@ fetch_from_repo() { # `lanes` writes nothing, and a rename is four files in one commit. Its write is # `lanes-edit.sh rename-lane`; the word exists because the tmux window and the # session's own name are not a register writer's to touch (clause (f)). -INSTALLABLES=(openRepoTools park resume status lane lanes lane-handoff lane-rename lanes-edit.sh lane-start lane-end link-estates repos.tsv) +# FIFTEEN SINCE opensoft/workBenches#119, on Brett Heap's home ruling of +# 2026-09-29 ("this work is really for openRepoTools repo"): `claude-current` +# is the Claude Code a launch starts — npm's published version, verified by +# `--version` and named by absolute path — which `lane-start` and workBenches' +# `claude-profile` call, and `claude-restart-check` is the RESTART NEEDED line +# the shared status line prints for a session whose binary moved on disk. They +# are placed at the END of the list, so every index a test takes into it holds. +INSTALLABLES=(openRepoTools park resume status lane lanes lane-handoff lane-rename lanes-edit.sh lane-start lane-end link-estates repos.tsv claude-current claude-restart-check) # ---------------------------------------------- THE WORDS THAT WERE RETIRED # @@ -385,7 +403,7 @@ mode_is() { # install_commands() { local dir="${OPENREPOTOOLS_BIN_DIR:-$HOME/.local/bin}" local name target source verb placed=0 - # ALL TWENTY-SEVEN IN HAND BEFORE ANY IS PLACED. The thirteen files, the + # ALL TWENTY-NINE IN HAND BEFORE ANY IS PLACED. The fifteen files, the # three skills, the three command files, and the TWO artifacts that are not # whole files — the two hook entries merged into one `settings.json` — # are all computed here, so a fetch that failed replaces nothing and a merge @@ -476,7 +494,7 @@ install_commands() { # ========================================================================= -# THE FOURTEEN ARTIFACTS THAT ARE NOT ONE OF THE THIRTEEN FILES +# THE FOURTEEN ARTIFACTS THAT ARE NOT ONE OF THE FIFTEEN FILES # ========================================================================= # # lane-collision-protocol Amendment 9(b), inheriting A8 Addendum 2's R-A8-5 @@ -494,7 +512,7 @@ install_commands() { # run and preserves hook keys. This command never writes a profile's # settings.json at all. One path, one writer, in both halves. # -# TWENTY-SEVEN ARTIFACTS, AND THE COUNT IS THE INVARIANT: thirteen files in the bin +# TWENTY-NINE ARTIFACTS, AND THE COUNT IS THE INVARIANT: fifteen files in the bin # directory, three skills in the shared skills directory, their three bare-run # copies, THREE COMMAND FILES at those same two paths, and TWO merged entries in # `~/.claude/settings.json`. It was twelve before Amendment 11 added a SECOND @@ -503,8 +521,9 @@ install_commands() { # `commands/swap.md`, eighteen before Amendment 12 adoption act 3 added the # `UserPromptSubmit` name guard beside the `SessionStart` entry, and nineteen # before Amendment 17 added `lane-handoff`, the `handoff` skill and the -# `/handoff` and `/ctx` command files, and twenty-six before Amendment 16 added -# `lane-rename`; the count moves +# `/handoff` and `/ctx` command files, twenty-six before Amendment 16 added +# `lane-rename`, and twenty-seven before opensoft/workBenches#119 added +# `claude-current` and `claude-restart-check`; the count moves # with the lists rather than being restated from memory. # # WHY A COMMAND FILE IS HERE AT ALL, WHEN ADOPTION ACT 6 SAYS THE LAUNCHER @@ -604,7 +623,7 @@ BLOCK # EVERY SKILL'S BYTES, by `--install`'s own rule: from the checkout beside this # file when run from one, else fetched at this same ref. ALL OF THEM IN HAND -# BEFORE ANY IS PLACED, which is the same all-or-nothing rule the thirteen files +# BEFORE ANY IS PLACED, which is the same all-or-nothing rule the fifteen files # already have — a new `/restart` beside a `handoff` that could not be fetched # is a half-install that reads like a whole one. collect_skills() { @@ -670,8 +689,8 @@ HOOK_PLAN="" # "absent" | "present" | "nofile" GUARD_PLAN="" # the same three, for Amendment 12's UserPromptSubmit entry HOOK_MERGED="" # a staged whole file, ready to move into place -# THE DESTINATIONS, PROVED WRITABLE BEFORE ANY OF THE THIRTEEN IS PLACED — every -# directory the artifacts that are not one of the thirteen files go into, derived +# THE DESTINATIONS, PROVED WRITABLE BEFORE ANY OF THE FIFTEEN IS PLACED — every +# directory the artifacts that are not one of the fifteen files go into, derived # from the two lists and NOT counted in this sentence. It said "THE THREE # DESTINATIONS" while the loop below built seven of them, which is the same # defect `collect_commands` names above: a number restated beside a list that @@ -714,7 +733,7 @@ plan_skill_targets() { # `~/.claude/skills/restart/SKILL.md` was followed and WRITTEN THROUGH: the # skill stays uninstalled, the target stays a link, and the bytes land # wherever it points. That is the same failure R-A9-12 was ruled on for the - # thirteen in the bin directory, one directory along, and the ruling says + # fifteen in the bin directory, one directory along, and the ruling says # nothing about which path it applies to. # # `unplaceable_kind` is asked rather than re-tested, because two copies of @@ -754,7 +773,7 @@ $bad rm -f -- $paths" } -# THE THIRTEEN TARGETS, PROVED PLACEABLE BEFORE ANY OF THE TWENTY-SEVEN IS PLACED +# THE FIFTEEN TARGETS, PROVED PLACEABLE BEFORE ANY OF THE TWENTY-NINE IS PLACED # (A9 Addendum 4, R-A9-12, ruled on F5 of the #24 review). # # `cp` FOLLOWS A SYMLINK. The loop below tests `[ -e "$target" ]`, compares @@ -779,7 +798,7 @@ $bad # `rm` that clears them — rather than leaving a person to remember a step. # WHAT IS AT A PATH, WHERE IT IS NOT A REGULAR FILE. # ONE implementation of R-A9-12's test, for every path this command writes: the -# thirteen in the bin directory, the six `SKILL.md`s (F-X17), the six command +# fifteen in the bin directory, the six `SKILL.md`s (F-X17), the six command # files, and `~/.claude/settings.json` (#44 round 1, `openRepoTools:878`). # Prints what is there and returns 0; returns 1 — saying nothing — where the # path is absent or is a regular file. @@ -814,9 +833,9 @@ path_kind() { # # This answered "a regular file" with `return 1` and asked nothing else, so # every existing file counted as one `--install` may place over — and # `plan_skill_targets` let a READ-ONLY `SKILL.md` or command file through. -# `install_commands` has already copied the thirteen PATH files by the time +# `install_commands` has already copied the fifteen PATH files by the time # `place_skill_and_hook` reaches the `cp`, so an ordinary permission failure -# there left a host with thirteen of the twenty-seven artifacts placed, on the one +# there left a host with fifteen of the twenty-nine artifacts placed, on the one # path in this file that promises all or none. A mode is not an exotic state # either: `chmod 444` on a file a person did not want overwritten is what a # person does. @@ -989,7 +1008,7 @@ $(guard_block_text)" # WHAT IS AT THAT PATH, BEFORE A BYTE OF IT IS READ (#44 round 1, # `openRepoTools:878`). This file carries the LAST TWO artifacts — the - # twenty-sixth and twenty-seventh — since Amendment 12 adoption act 3 put the + # twenty-eighth and twenty-ninth — since Amendment 12 adoption act 3 put the # name guard beside the `SessionStart` entry, and it is the only path no # walk asked about; the # `present` arm now CHMODs it, and `chmod` @@ -1188,7 +1207,7 @@ $(guard_block_text)" fi } -# The thirteen placements, AFTER the thirteen files and the merge are all in hand. +# The thirteen placements, AFTER the fifteen files and the merge are all in hand. place_skill_and_hook() { local shared_dir bare_dir settings target verb hook_tmp name settings="$(claude_home)/settings.json" @@ -1199,7 +1218,7 @@ place_skill_and_hook() { # EVERY FAILURE HERE IS A `die`, never the shell's own 1. `--install`'s # codes are this toolset's — 0 done, 1 findings printed, 2 a refusal — and # an unguarded `mkdir` or `cp` under `set -e` exits 1, which reads as a - # report of findings from a command that placed thirteen of twenty-seven artifacts. + # report of findings from a command that placed fifteen of twenty-nine artifacts. for target in "$shared_dir/SKILL.md" "$bare_dir/SKILL.md"; do mkdir -p -- "$(dirname -- "$target")" || die "could not create $(dirname -- "$target") for the $name skill. @@ -1224,7 +1243,7 @@ place_skill_and_hook() { the skills and the hook are not." fi # 644: a SKILL.md is a document a session READS, not a command. The - # thirteen in the bin directory are stamped 755 for the reason that loop + # fifteen in the bin directory are stamped 755 for the reason that loop # gives — "a copy that is not executable is not a command" — and # that reason does not reach this file. Amendment 9(b) describes # `--install` as stamping 755 "on everything it places" while @@ -1246,7 +1265,7 @@ place_skill_and_hook() { # reason a skill has two. # # 644 for the same reason a `SKILL.md` is: a command file is a document a - # session READS. The 755 argument belongs to the thirteen in the bin + # session READS. The 755 argument belongs to the fifteen in the bin # directory, where a copy that is not executable is not a command. for name in "${COMMANDS[@]}"; do for target in "$(skills_home)/shared/commands/$name.md" "$(claude_home)/commands/$name.md"; do @@ -1290,8 +1309,8 @@ place_skill_and_hook() { # round 1, the suppressed comment at `openRepoTools:880`): a # `settings.json` this euid can write but does not own takes `jq` and # every check `plan_hook_merge` puts to it, then refuses the mode - # stamp, with the thirteen commands, the skills and the command files — - # twenty-five of the twenty-seven artifacts — already placed. The guard that + # stamp, with the fifteen commands, the skills and the command files — + # twenty-seven of the twenty-nine artifacts — already placed. The guard that # would catch it first is the same `[ -O ]` `unplaceable_kind`'s own # comment declines, for the same reason: no test in this suite can # make a second account to own this file, and the check would refuse a diff --git a/tests/test_install_skill_and_hook.py b/tests/test_install_skill_and_hook.py index 0806a61..b04f15c 100644 --- a/tests/test_install_skill_and_hook.py +++ b/tests/test_install_skill_and_hook.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -"""`--install`'s artifacts that are not one of the THIRTEEN files: the three +"""`--install`'s artifacts that are not one of the FIFTEEN files: the three skills at two paths each, the three command files at two more each, and the TWO merged hook entries. @@ -17,7 +17,7 @@ on the command string. So the tests below are about the four answers that string can have — present, absent, differing, unreadable — asked of EACH entry, and about the one rule that makes a wrong answer survivable: both merges are -computed with the thirteen files in hand, BEFORE any of them is placed, so a +computed with the fifteen files in hand, BEFORE any of them is placed, so a refusal costs a whole install rather than half of one — and, since the pair, a settings file never passes through a state carrying one entry of the two. @@ -550,7 +550,7 @@ def test_a_differing_session_start_entry_refuses_and_places_nothing(tmp_path): twice. AND THE COST IS A WHOLE INSTALL, NOT HALF OF ONE: both merges are computed - with the thirteen files in hand, before any of them is placed, so the bin + with the fifteen files in hand, before any of them is placed, so the bin directory is untouched. That is the same all-or-nothing rule `--install` already had, extended to the two artifacts that are not whole files. """ @@ -663,16 +663,16 @@ def test_a_destination_that_cannot_be_written_refuses_before_anything_is_placed( Amendment 9(b) computes the merge in hand "so a merge that cannot be computed refuses having placed nothing". A filesystem offers no transaction - across twenty-seven artifacts, so nothing can make the last fourteen atomic - with the first thirteen — but the failure that actually happens is not an + across twenty-nine artifacts, so nothing can make the last fourteen atomic + with the first fifteen — but the failure that actually happens is not an exotic one, it is a directory that is not this installer's to write, and that question can be asked in the planning phase where the refusal still costs nothing. - Without the check the run places thirteen files and some of the remaining + Without the check the run places fifteen files and some of the remaining artifacts, then dies — leaving a host with commands installed, neither hook entry, and an installer that reports the same "already - installed (unchanged)" for the thirteen on every re-run while never reaching + installed (unchanged)" for the fifteen on every re-run while never reaching the one that failed. """ if which == "shared skills": @@ -687,13 +687,13 @@ def test_a_destination_that_cannot_be_written_refuses_before_anything_is_placed( assert result.returncode == 2, result.stdout + result.stderr assert "NOTHING was installed" in result.stderr assert not bin_dir.exists() or not any(bin_dir.iterdir()), ( - "the thirteen files were placed against a destination that was never " + "the fifteen files were placed against a destination that was never " "going to take the other fourteen") finally: blocked.chmod(0o700) -# --- the thirteen targets, and what they are (R-A9-12) --------------------- +# --- the fifteen targets, and what they are (R-A9-12) ---------------------- @NEEDS_JQ @NOT_ROOT @@ -714,7 +714,7 @@ def test_a_leaf_destination_that_exists_unwritable_refuses_before_anything_is_pl The bin directory is the assertion that tells the two apart: the refusal here is a PLANNING one and nothing is placed, where the mutant places all - thirteen files and dies on the `cp` into this same directory, which is the + fifteen files and dies on the `cp` into this same directory, which is the half-install the planning phase exists to prevent. """ blocked = tmp_path / ".claude-profiles" / "shared" / "skills" / SKILL_DIR_NAME @@ -727,7 +727,7 @@ def test_a_leaf_destination_that_exists_unwritable_refuses_before_anything_is_pl assert "is not writable" in result.stderr, result.stderr assert "NOTHING was installed" in result.stderr assert not bin_dir.exists() or not any(bin_dir.iterdir()), ( - "the thirteen files were placed against a leaf directory that was " + "the fifteen files were placed against a leaf directory that was " "never going to take the skill") finally: blocked.chmod(0o700) @@ -796,7 +796,7 @@ def test_a_symlinked_target_is_refused_and_nothing_is_written_through_it(tmp_pat def test_a_directory_where_a_command_goes_is_refused_the_same_way(tmp_path): """THE RULE IS `A REGULAR FILE`, not `not a symlink`. A directory at `$BIN/park` is the same refusal for the same reason — `cp` cannot place a - file over it, and finding that out after twelve of the thirteen are placed is + file over it, and finding that out after fourteen of the fifteen are placed is the half-install the planning phase exists to prevent.""" bin_dir = tmp_path / ".local" / "bin" (bin_dir / "park").mkdir(parents=True) @@ -1057,7 +1057,7 @@ def test_a_symlinked_skill_target_is_refused_and_nothing_written_through_it( tmp_path, shared, name): """R-A9-12 IS ABOUT WHAT `cp` DOES, NOT ABOUT WHICH DIRECTORY (F-X17). - `plan_install_targets` refuses a symlink for every one of the thirteen files + `plan_install_targets` refuses a symlink for every one of the fifteen files in the bin directory. `plan_skill_targets` proved only the DIRECTORIES writable, and `place_skill_and_hook` then reached each `SKILL.md` with `[ -e ]`, `cmp -s` and `cp` — none of which can tell a regular file from a @@ -1091,7 +1091,7 @@ def test_a_symlinked_skill_target_is_refused_and_nothing_written_through_it( f"the refusal must print the exact `rm` that clears it:\n{result.stderr}") assert far.read_bytes() == before, f"--install wrote through the link into {far}" assert target.is_symlink(), f"{target} is no longer the link it was" - # AND IT REFUSED IN THE PLANNING PHASE: the thirteen commands never arrived + # AND IT REFUSED IN THE PLANNING PHASE: the fifteen commands never arrived # either, which is what makes `NOTHING was installed` true rather than # nearly true. assert not bin_dir.exists() or not any(bin_dir.iterdir()), ( diff --git a/tests/test_openrepotools_command.py b/tests/test_openrepotools_command.py index 1908631..713003d 100644 --- a/tests/test_openrepotools_command.py +++ b/tests/test_openrepotools_command.py @@ -56,10 +56,14 @@ #: under Amendment 17(a), which made the list TWELVE; `lane-rename` joined it #: under Amendment 16 (ratified 2026-09-14T09:24:35Z), whose clause (h) is why #: it is a word rather than a `lanes` option — `lanes` writes nothing and a -#: rename is four files in one commit — which is why the list is THIRTEEN. +#: rename is four files in one commit — which made the list THIRTEEN. +#: `claude-current` and `claude-restart-check` joined it for +#: opensoft/workBenches#119 (Brett Heap's home ruling of 2026-09-29), at the END +#: so every index taken below still names the file it did: the list is FIFTEEN. INSTALLED = ("openRepoTools", "park", "resume", "status", "lane", "lanes", "lane-handoff", "lane-rename", "lanes-edit.sh", "lane-start", - "lane-end", "link-estates", "repos.tsv") + "lane-end", "link-estates", "repos.tsv", "claude-current", + "claude-restart-check") #: The skills `--install` also places, at two paths each, and the paths they are #: fetched from when there is no checkout to copy them out of (Amendment 9(b), @@ -81,11 +85,11 @@ COMMAND_NAMES = ("handoff", "ctx", "swap") COMMAND_PATHS = tuple(f"commands/{n}.md" for n in COMMAND_NAMES) -#: Everything a stdin install has to fetch: the thirteen files, the three skills +#: Everything a stdin install has to fetch: the fifteen files, the three skills #: and the three command files. FETCHED = INSTALLED + SKILL_PATHS + COMMAND_PATHS -#: TWENTY-SEVEN ARTIFACTS, AND THE COUNT IS THE INVARIANT: thirteen files in the +#: TWENTY-NINE ARTIFACTS, AND THE COUNT IS THE INVARIANT: fifteen files in the #: bin directory, three skills in the shared skills directory, their three #: bare-run copies, three command files at that same pair of destinations, and #: TWO merged entries in `~/.claude/settings.json`. Derived from the three @@ -104,7 +108,7 @@ + HOOK_ENTRIES) USAGE_LINES = ( - "openRepoTools --install install (or update) the thirteen estate and", + "openRepoTools --install install (or update) the fifteen estate and", "openRepoTools wip init create your workspace repository, clone it,", "openRepoTools --help | --version", ) @@ -117,7 +121,7 @@ WINDOWS_SKIP] #: `--install` HARD-REQUIRES `jq` SINCE lane-collision-protocol AMENDMENT 9(b): -#: two of its twenty-seven artifacts are merged entries inside a JSON file somebody +#: two of its twenty-nine artifacts are merged entries inside a JSON file somebody #: else owns, and the clause has it refuse naming `jq` rather than rewriting #: that file by hand. So a run of `--install` on a host without `jq` is a #: REFUSAL BY DESIGN, and a test that asserts a successful placement there is @@ -189,7 +193,7 @@ def test_help_prints_every_usage_line(): def test_help_names_every_command_it_places_and_the_standards_front_door(): - """`--install` places thirteen files, and twelve of them are commands this one + """`--install` places fifteen files, and fourteen of them are commands this one knows nothing about — so `--help` has to say what they are and where the rest is written down. A command a person has on PATH and cannot find written down is a command they will not use. @@ -205,7 +209,8 @@ def test_help_names_every_command_it_places_and_the_standards_front_door(): assert result.returncode == 0, result.stderr for line in ("park []", "resume []", "status []", "lane-start ", "lane-end ", - "lanes-edit.sh ", "link-estates", "repos.tsv"): + "lanes-edit.sh ", "link-estates", "repos.tsv", + "claude-current [--porcelain]", "claude-restart-check"): assert line in result.stdout, line assert "`openRepoShape` is the standard's front door" in result.stdout assert "this command scaffolds none" in result.stdout @@ -377,7 +382,7 @@ def test_installing_twice_changes_nothing(tmp_path): assert second.returncode == 0, second.stderr for name in INSTALLED: assert f"{name}: already installed at" in second.stdout, name - # TWENTY-SEVEN, not thirteen: the six skill copies, the six command-file copies + # TWENTY-NINE, not fifteen: the six skill copies, the six command-file copies # and BOTH hook entries each report `unchanged` too, and the count is the # invariant Amendment 9(b) names — derived from the three lists, never # restated, so a new skill or command moves it. It was eighteen until @@ -390,7 +395,7 @@ def test_installing_twice_changes_nothing(tmp_path): @NEEDS_JQ def test_install_replaces_a_copy_that_has_drifted(tmp_path, name): """Per file, and only the one that drifted: an install that rewrote all - thirteen every time would have nothing to say about which one was stale.""" + fifteen every time would have nothing to say about which one was stale.""" assert run_cmd("--install", home=tmp_path).returncode == 0 target = tmp_path / ".local" / "bin" / name target.write_text(target.read_text(encoding="utf-8") + "# drift\n", From b8e2ad1676430f932e01a6ce7aeee09cd5fb8fbf Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:45:30 +0000 Subject: [PATCH 05/18] claude-current: a malformed bound exits 64, the usage status, and not 1 The workBenches side (opensoft/workBenches#121, 016-launch-current-claude) reports claude-current's exit 1 as "Claude CLI not found.", which is what 1 means for every other cause. A CLAUDE_CURRENT_* bound that is not a whole number of seconds is a typo in a setting, not a missing Claude Code, so it exits 64 now, as an argument it does not know already did. The launcher there already refuses on 64 and names the status. `lane-start` ends with 1 for any failure but 2, as before, and its message says "named no Claude Code to launch" so it reads true for 64 as well as 1. The header, the usage text, the manual and both suites say the same. Refs opensoft/workBenches#119 Lane: openxfactory-5 (openXfactory-5) Co-Authored-By: Claude Opus 5.5 --- claude-current | 11 +++++++---- docs/README-claude-current.md | 15 ++++++++------- lane-start | 13 +++++++------ tests/test_claude_current.py | 4 +++- tests/test_lane_start_claude_current.py | 1 + 5 files changed, 26 insertions(+), 18 deletions(-) diff --git a/claude-current b/claude-current index 638503b..2aaf2bd 100755 --- a/claude-current +++ b/claude-current @@ -62,9 +62,11 @@ # EXIT # 0 resolved: verified, ahead or unverified, and stale only under # CLAUDE_ALLOW_STALE=1 -# 1 no runnable Claude Code at all, or a malformed setting +# 1 no runnable Claude Code at all # 2 refused: every candidate is still behind npm after the update attempt -# 64 usage +# 64 usage: an argument it does not know, or a CLAUDE_CURRENT_* bound that +# is not a whole number of seconds. Kept apart from 1, which a launcher +# reports as "no Claude Code" (opensoft/workBenches#121) # # ENVIRONMENT # CLAUDE_ALLOW_STALE=1 launch a stale candidate anyway, and say so @@ -113,7 +115,8 @@ first when every installed copy is behind. Never resolved through PATH. (status is verified, ahead, unverified or stale) --offline no npm read and no update: the newest installed copy, unverified -Exit 0 resolved, 1 nothing runnable, 2 refused as stale, 64 usage. +Exit 0 resolved, 1 nothing runnable, 2 refused as stale, 64 usage or a +malformed CLAUDE_CURRENT_* bound. CLAUDE_ALLOW_STALE=1 launches a stale copy anyway. The CLAUDE_CURRENT_* variables and the hand-off to lane-start are in docs/README-claude-current.md. USAGE @@ -156,7 +159,7 @@ for setting in TIMEOUT VERSION_TIMEOUT UPDATE_TIMEOUT LOCK_WAIT; do eval "setting_value=\${$setting}" # shellcheck disable=SC2154 posint "$setting_value" || - die "CLAUDE_CURRENT_$setting must be a whole number of seconds above 0, and it is '$setting_value'" 1 + die "CLAUDE_CURRENT_$setting must be a whole number of seconds above 0, and it is '$setting_value'" 64 done NATIVE_DIR="${CLAUDE_CURRENT_NATIVE_DIR:-$HOME/.local/share/claude/versions}" CACHE_DIR="${CLAUDE_CURRENT_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/openrepotools}" diff --git a/docs/README-claude-current.md b/docs/README-claude-current.md index 8fd2fdb..981ed16 100644 --- a/docs/README-claude-current.md +++ b/docs/README-claude-current.md @@ -95,9 +95,10 @@ stderr carries one line saying what was chosen, for example `claude-current: claude 2.1.284 (verified against npm 2.1.284) at `. A refusal prints nothing on stdout. -**Exit.** 0 resolved; 1 no runnable Claude Code at all, or a malformed -setting; 2 refused, because every candidate is still behind npm after the -update; 64 usage. +**Exit.** 0 resolved; 1 no runnable Claude Code at all; 2 refused, because +every candidate is still behind npm after the update; 64 usage, including a +`CLAUDE_CURRENT_*` bound that is not a whole number of seconds. 64 is kept +apart from 1 because a launcher reports 1 as "no Claude Code". | Variable | Default | What it is | |---|---|---| @@ -153,10 +154,10 @@ and the launch, and before step 5 writes anything: - Exit 0: the launch command's first word becomes the absolute path. `CLAUDE_BIN` and `CLAUDE_RESOLVED_BIN` are exported with that path, and one line says `launching claude () at `. -- Exit 2 ends `lane-start` with 2 and exit 1 with 1. Either way the row, the - object log and the handoff's Rule 3 stamp are not written. The window has - already been renamed for the lane by step 4, as it has for the launcher - table's own check on the agent's command. +- Exit 2 ends `lane-start` with 2, and any other failure with 1. Either way + the row, the object log and the handoff's Rule 3 stamp are not written. The + window has already been renamed for the lane by step 4, as it has for the + launcher table's own check on the agent's command. - With no `claude-current` installed, one note says so, and `CLAUDE_BIN` (`claude` from `PATH` when unset) is launched unchecked, as before. - `--dry-run` prints a `PLAN` line naming the call and runs nothing, because diff --git a/lane-start b/lane-start index 952c120..600ecbf 100755 --- a/lane-start +++ b/lane-start @@ -2657,11 +2657,12 @@ cmd+=(${pass[@]+"${pass[@]}"}) # (opensoft/workBenches#119). `claude-current --porcelain` reads npm's published # version, updates the user-writable install when every copy is behind, and # answers with an absolute path it has run `--version` on. Its exit 2 is a -# refusal (every copy still behind npm) and its 1 an environment with nothing -# runnable; either ends this run with the same status, before the row, the log -# and the handoff are written, which is where the launcher table's own PATH -# check sits for the same reason. Its stderr is the operator's to read and goes -# straight through. Never under `--no-launch`, which launches nothing and is +# refusal (every copy still behind npm) and ends this run with 2; any other +# failure (1, nothing runnable; 64, a malformed setting) ends it with 1, the +# environment status. Either way the run ends before the row, the log and the +# handoff are written, which is where the launcher table's own PATH check sits +# for the same reason. Its stderr is the operator's to read and goes straight +# through. Never under `--no-launch`, which launches nothing and is # the first act INSIDE a running session, and never under `--dry-run`, which # must not update anything. if [ "$AGENT" = claude ] && (( lane_claude_resolve )) && (( ! no_launch )); then @@ -2675,7 +2676,7 @@ if [ "$AGENT" = claude ] && (( lane_claude_resolve )) && (( ! no_launch )); then case "$lcc_rc" in 0) : ;; 2) die "claude-current refused the launch (exit 2, its reason above): the newest Claude Code installed is behind the one npm publishes. Nothing was written: the row, the object log and the handoff's Rule 3 stamp all come after this. CLAUDE_ALLOW_STALE=1 $prog … launches it anyway, or CLAUDE_BIN= pins one." 2 ;; - *) die "claude-current found no Claude Code to launch (exit $lcc_rc, its reason above). Nothing was written: the row, the object log and the handoff's Rule 3 stamp all come after this." 1 ;; + *) die "claude-current named no Claude Code to launch (exit $lcc_rc, its reason above). Nothing was written: the row, the object log and the handoff's Rule 3 stamp all come after this." 1 ;; esac lcc_path=""; lcc_version=""; lcc_status="" while IFS= read -r lcc_line; do diff --git a/tests/test_claude_current.py b/tests/test_claude_current.py index 97ce505..40f3c29 100644 --- a/tests/test_claude_current.py +++ b/tests/test_claude_current.py @@ -599,8 +599,10 @@ def test_help_and_an_unknown_argument(box): @pytest.mark.parametrize("name", ["TIMEOUT", "VERSION_TIMEOUT", "UPDATE_TIMEOUT", "LOCK_WAIT"]) def test_a_malformed_bound_is_refused_before_anything_runs(box, name): + """64, the usage status, and not 1: a launcher reports 1 as "no Claude + Code", which a typo in a bound is not (opensoft/workBenches#121).""" result = box.run(**{f"CLAUDE_CURRENT_{name}": "soon"}) - assert result.returncode == 1 + assert result.returncode == 64 assert f"CLAUDE_CURRENT_{name} must be a whole number" in result.stderr assert box.npm_log.read_text() == "" diff --git a/tests/test_lane_start_claude_current.py b/tests/test_lane_start_claude_current.py index 8dea251..b4f15a1 100644 --- a/tests/test_lane_start_claude_current.py +++ b/tests/test_lane_start_claude_current.py @@ -297,6 +297,7 @@ def test_a_refusal_ends_the_run_with_2_before_anything_is_written(box): @pytest.mark.parametrize("fake", [ {"FAKE_CC_RC": "1"}, + {"FAKE_CC_RC": "64"}, {"FAKE_CC_RAW": "not porcelain"}, {"FAKE_CC_PATH": "relative/claude"}, {"FAKE_CC_PATH": "/nonexistent/claude-current-test/claude"}, From e2644af3a6771e63a170f0f52806e63b6a9d35ec Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:59:36 +0000 Subject: [PATCH 06/18] Copilot's second round on #134: SemVer pre-release order, the installed version across both families, and the bare --confirm launch resolves Three findings, all real: - Pre-release tags were compared as one string, so `2.1.284-beta.9` sorted after `2.1.284-beta.10` and a launch could take an older pre-release as ahead of npm. `precmp` compares the dot-separated identifiers by SemVer 2.0.0 rule 11: numbers as numbers and before words, words in byte order (LC_ALL=C), fewer identifiers lower. It is in both commands byte for byte, like `numcmp` and `vercmp`; the suite checks the identity and runs rule 11's own example chain. - claude-restart-check read the installed version only from the running binary's family, so an npm 2.1.283 session beside a native 2.1.284 said nothing, though the next launch starts the native one. It now takes the highest of the running install's own, the native versions directory ($CLAUDE_CURRENT_NATIVE_DIR) and the user npm prefix's package, which it finds with claude-current's `npm_user_prefix`, byte for byte. No `$HOME` reads only the running install. Its suite now runs with `$HOME` and the npm prefix inside the fixture. - lane-start's bare launch, the one a `--confirm` answered No takes, came before the resolution and started `claude` from PATH. The resolution is one function now, `lane_claude_resolve_launch`, called from that launch and from the foot of 5a, and it refuses a stale copy in both. The bare path gains one line; step 4 is untouched. Also: the lane-start suite asks for the `claude ` sub-field by name rather than at the end of the payload, because Amendment 18 (#83) appends `host`, `os` and `container` after it (measured on a test-merge of #83). Counts: tests/test_claude_current.py 70, tests/test_claude_restart_check.py 19, tests/test_lane_start_claude_current.py 20. Refs opensoft/workBenches#119 Lane: openxfactory-5 (openXfactory-5) Co-Authored-By: Claude Opus 5.5 --- claude-current | 40 ++++++++- claude-restart-check | 106 +++++++++++++++++++--- docs/README-claude-current.md | 30 ++++--- lane-start | 115 ++++++++++++++---------- tests/test_claude_current.py | 43 ++++++++- tests/test_claude_restart_check.py | 59 +++++++++++- tests/test_lane_start_claude_current.py | 50 +++++++++-- 7 files changed, 358 insertions(+), 85 deletions(-) diff --git a/claude-current b/claude-current index 2aaf2bd..6184508 100755 --- a/claude-current +++ b/claude-current @@ -231,8 +231,44 @@ numcmp() { if [[ "$x" > "$y" ]]; then echo 1; else echo -1; fi } +# precmp : -1, 0 or 1 for two pre-release tags, by SemVer 2.0.0 rule 11: +# dot-separated identifiers from the left; digits-only ones compare as numbers +# and sort before the others, which compare in byte order (LC_ALL=C); when +# every shared identifier is equal, the tag with fewer identifiers is lower. +# So beta.9 < beta.10, and alpha < alpha.1 < alpha.beta. +precmp() { + local a="$1" b="$2" x y xn yn c + while [ -n "$a" ] || [ -n "$b" ]; do + [ -n "$a" ] || { echo -1; return 0; } + [ -n "$b" ] || { echo 1; return 0; } + x="${a%%.*}" + y="${b%%.*}" + case "$a" in *.*) a="${a#*.}" ;; *) a="" ;; esac + case "$b" in *.*) b="${b#*.}" ;; *) b="" ;; esac + xn=1 + yn=1 + case "$x" in '' | *[!0-9]*) xn=0 ;; esac + case "$y" in '' | *[!0-9]*) yn=0 ;; esac + if [ "$xn$yn" = 11 ]; then + c="$(numcmp "$x" "$y")" + elif [ "$xn$yn" = 10 ]; then + c=-1 + elif [ "$xn$yn" = 01 ]; then + c=1 + elif [ "$x" = "$y" ]; then + c=0 + elif (LC_ALL=C; [[ "$x" < "$y" ]]); then + c=-1 + else + c=1 + fi + [ "$c" = 0 ] || { echo "$c"; return 0; } + done + echo 0 +} + # vercmp : -1, 0 or 1. Numeric on the x.y.z core. Then a version with no -# pre-release tag beats one with a tag, and two tags compare as strings. Build +# pre-release tag beats one with a tag, and two tags compare by precmp. Build # metadata (+...) is ignored. vercmp() { local a="${1%%+*}" b="${2%%+*}" ac bc ap="" bp="" ar br i c @@ -251,7 +287,7 @@ vercmp() { if [ "$ap" = "$bp" ]; then echo 0; return 0; fi if [ -z "$ap" ]; then echo 1; return 0; fi if [ -z "$bp" ]; then echo -1; return 0; fi - if [[ "$ap" > "$bp" ]]; then echo 1; else echo -1; fi + precmp "$ap" "$bp" } # physical : the path with every symlink resolved, without `readlink -f`, diff --git a/claude-restart-check b/claude-restart-check index e00f4e7..835d97d 100755 --- a/claude-restart-check +++ b/claude-restart-check @@ -33,13 +33,18 @@ # - The binary was REPLACED when that exe link ends in " (deleted)". npm # renames the old package directory before it deletes it, so the exe path # cannot be used to read the running version. -# - The INSTALLED version is read from disk: the highest x.y.z name in the -# native versions directory, or `version` in the npm package's -# package.json at its canonical path. +# - The INSTALLED version is read from disk, as the highest of: the running +# install's own (the highest x.y.z name beside a native version file, or +# `version` in the npm package's package.json at its canonical path), the +# native versions directory ($CLAUDE_CURRENT_NATIVE_DIR, default +# ~/.local/share/claude/versions), and the package in the user npm prefix, +# found the way claude-current finds it. So a session on the npm copy is +# told about a newer native install, which the next launch starts. # - It warns when the binary was replaced, or when the installed version is # newer than the running one (--running, else the native file's name). -# It reads /proc and two small files. It reaches no network and runs no -# binary, so the render is not slowed by a `claude --version`. +# It reads /proc, a directory listing and a few small files. It reaches no +# network and runs no binary, so the render is not slowed by a +# `claude --version`. # # OUTPUT # Nothing, or exactly one line: @@ -54,6 +59,9 @@ # ENVIRONMENT # CLAUDE_RESTART_CHECK_PROC the process table to read (default /proc), the # test seam +# CLAUDE_CURRENT_NATIVE_DIR, CLAUDE_CURRENT_NPM_PREFIX, NPM_CONFIG_PREFIX +# where the installs are, read as claude-current +# reads them # NO_COLOR plain text, no ANSI colour # # `set -u` and NOT `set -e`, deliberately, for lane-handoff's reason one file @@ -117,8 +125,8 @@ version_re='^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.+-]+)?$' core_re='^[0-9]+\.[0-9]+\.[0-9]+$' is_version() { [[ "${1:-}" =~ $version_re ]]; } -# numcmp and vercmp are claude-current's, byte for byte, so the two commands -# agree on which version is newer. +# numcmp, precmp and vercmp are claude-current's, byte for byte, so the two +# commands agree on which version is newer. numcmp() { local x="$1" y="$2" x="${x#"${x%%[!0]*}"}" @@ -133,6 +141,42 @@ numcmp() { if [[ "$x" > "$y" ]]; then echo 1; else echo -1; fi } +# precmp : -1, 0 or 1 for two pre-release tags, by SemVer 2.0.0 rule 11: +# dot-separated identifiers from the left; digits-only ones compare as numbers +# and sort before the others, which compare in byte order (LC_ALL=C); when +# every shared identifier is equal, the tag with fewer identifiers is lower. +# So beta.9 < beta.10, and alpha < alpha.1 < alpha.beta. +precmp() { + local a="$1" b="$2" x y xn yn c + while [ -n "$a" ] || [ -n "$b" ]; do + [ -n "$a" ] || { echo -1; return 0; } + [ -n "$b" ] || { echo 1; return 0; } + x="${a%%.*}" + y="${b%%.*}" + case "$a" in *.*) a="${a#*.}" ;; *) a="" ;; esac + case "$b" in *.*) b="${b#*.}" ;; *) b="" ;; esac + xn=1 + yn=1 + case "$x" in '' | *[!0-9]*) xn=0 ;; esac + case "$y" in '' | *[!0-9]*) yn=0 ;; esac + if [ "$xn$yn" = 11 ]; then + c="$(numcmp "$x" "$y")" + elif [ "$xn$yn" = 10 ]; then + c=-1 + elif [ "$xn$yn" = 01 ]; then + c=1 + elif [ "$x" = "$y" ]; then + c=0 + elif (LC_ALL=C; [[ "$x" < "$y" ]]); then + c=-1 + else + c=1 + fi + [ "$c" = 0 ] || { echo "$c"; return 0; } + done + echo 0 +} + vercmp() { local a="${1%%+*}" b="${2%%+*}" ac bc ap="" bp="" ar br i c ac="${a%%-*}" @@ -150,7 +194,7 @@ vercmp() { if [ "$ap" = "$bp" ]; then echo 0; return 0; fi if [ -z "$ap" ]; then echo 1; return 0; fi if [ -z "$bp" ]; then echo -1; return 0; fi - if [[ "$ap" > "$bp" ]]; then echo 1; else echo -1; fi + precmp "$ap" "$bp" } is_claude_binary() { # @@ -184,6 +228,33 @@ package_version() { sed -n -e 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*$/\1/p' "$1" 2>/dev/null | head -n 1 } +# The user npm prefix, found the way claude-current finds it (byte for byte), +# because that is the prefix a launch updates and starts from. +npm_user_prefix() { + local p="" + if [ -n "${CLAUDE_CURRENT_NPM_PREFIX:-}" ]; then + p="$CLAUDE_CURRENT_NPM_PREFIX" + elif [ -n "${NPM_CONFIG_PREFIX:-}" ]; then + p="$NPM_CONFIG_PREFIX" + elif [ -n "${npm_config_prefix:-}" ]; then + p="$npm_config_prefix" + elif [ -r "$HOME/.npmrc" ]; then + p="$(sed -n -e 's/^[[:space:]]*prefix[[:space:]]*=[[:space:]]*//p' "$HOME/.npmrc" | tail -n 1)" + p="${p%"${p##*[![:space:]]}"}" + p="${p%\"}" + p="${p#\"}" + fi + case "$p" in + '~') p="$HOME" ;; + '~/'*) p="$HOME/${p#'~/'}" ;; + '${HOME}'*) p="$HOME${p#'${HOME}'}" ;; + '$HOME'*) p="$HOME${p#'$HOME'}" ;; + esac + p="${p%/}" + [ -n "$p" ] || p="$HOME/.npm-global" + printf '%s\n' "$p" +} + pid="$start_pid" exe="" target="" @@ -207,18 +278,33 @@ done replaced=0 [ "$exe" = "$target" ] || replaced=1 +# THE INSTALLED VERSION IS THE HIGHEST ONE A LAUNCH COULD START, from every +# install claude-current reads that has its version on disk: the running +# binary's own, the native versions directory and the user npm prefix's +# package. A session on the npm copy is behind a newer native install, which +# the next launch starts (Copilot on opensoft/openRepoTools#134). installed="" from_path="" +consider() { # : kept when it is higher than the one in hand + is_version "${1:-}" || return 0 + if [ -z "$installed" ] || [ "$(vercmp "$1" "$installed")" = 1 ]; then + installed="$1" + fi +} case "$target" in */claude/versions/*) - installed="$(highest_in "${target%/*}")" + consider "$(highest_in "${target%/*}")" from_path="${target##*/}" ;; */node_modules/@anthropic-ai/*) - installed="$(package_version "${target%%/node_modules/@anthropic-ai/*}/node_modules/@anthropic-ai/claude-code/package.json")" + consider "$(package_version "${target%%/node_modules/@anthropic-ai/*}/node_modules/@anthropic-ai/claude-code/package.json")" if [ "$replaced" = 0 ]; then from_path="$(package_version "${target%/bin/*}/package.json")" fi ;; esac +if [ -n "${HOME:-}" ]; then + consider "$(highest_in "${CLAUDE_CURRENT_NATIVE_DIR:-$HOME/.local/share/claude/versions}")" + consider "$(package_version "$(npm_user_prefix)/lib/node_modules/@anthropic-ai/claude-code/package.json")" +fi is_version "$running" || running="$from_path" is_version "$running" || running="" diff --git a/docs/README-claude-current.md b/docs/README-claude-current.md index 981ed16..1988b86 100644 --- a/docs/README-claude-current.md +++ b/docs/README-claude-current.md @@ -64,6 +64,10 @@ It then asks npm, bounded by `$CLAUDE_CURRENT_TIMEOUT` seconds: | still behind, `CLAUDE_ALLOW_STALE=1` | the newest candidate, and says `STALE` | `stale` | An equal candidate beats a newer one. `ahead` is a launch, not a refusal. +"Newer" is SemVer's order, in both commands alike: numeric on x.y.z, a +release above its own pre-releases, and pre-release identifiers compared +one by one, numbers as numbers (`beta.9` before `beta.10`) and words in byte +order. Build metadata after a `+` is ignored. **The update runs under a lock** at `${XDG_CACHE_HOME:-~/.cache}/openrepotools/claude-current.lock`: `flock` where @@ -143,7 +147,8 @@ an inherited answer from an operator's pin: ## `lane-start` `lane-start` applies that table at the end of step 5a, once it knows the agent -and the launch, and before step 5 writes anything: +and the launch, and before step 5 writes anything (and in one earlier launch, +below): - It looks for `claude-current` beside itself, then in `$OPENREPOTOOLS_BIN_DIR` (default `~/.local/bin`), and never on `PATH`. A @@ -178,11 +183,9 @@ A pinned launch records no version, and neither does a line written by the deferred `--no-launch` act. A value that is not x.y.z is left off with a note, because the log is append-only. -One path is not covered yet. A `--confirm` answered No launches -`$CLAUDE_BIN` bare, without a lane, and it does not ask `claude-current`: an -unset `CLAUDE_BIN` there is still `claude` from `PATH`. Through -`claude-profile`, which hands on its own resolved path, that launch is -current anyway. +A `--confirm` answered No launches Claude bare, without a lane and writing +nothing, and that launch resolves the same way, refusal included. It is the +one launch that happens before step 4. ## `claude-restart-check` @@ -205,8 +208,13 @@ It warns when either of these holds: names a directory that no longer exists, and it cannot give the running version. - **The installed version is newer than the running one.** The installed - version is the highest x.y.z name in the native versions directory, or the - `version` in the npm package's `package.json` at its canonical path. The + version is the highest of three, each read from disk: the running install's + own (the highest x.y.z name beside a native version file, or the `version` + in the npm package's `package.json` at its canonical path), the native + versions directory (`$CLAUDE_CURRENT_NATIVE_DIR`, default + `~/.local/share/claude/versions`), and the package in the user npm prefix, + found the way `claude-current` finds it. So a session on the npm copy is + told about a newer native install, which the next launch starts. The running version is `--running` (the status line JSON's `version`), else the native file's own name. @@ -217,9 +225,9 @@ RESTART NEEDED: running 2.1.283, installed 2.1.284; /ctx at your next breakpoint ``` It never acts. There is no kill and no automatic `/ctx`, because a working -session is never interrupted. It reads `/proc` and two small files, reaches no -network and runs no binary, so the render is not slowed by a `claude ---version`. It always exits 0, on a host with no `/proc` such as macOS too, +session is never interrupted. It reads `/proc`, one directory listing and a +few small files, reaches no network and runs no binary, so the render is not +slowed by a `claude --version`. It always exits 0, on a host with no `/proc` such as macOS too, where it prints nothing: a status line must never break on this. 64 is kept for an argument it does not know. `CLAUDE_RESTART_CHECK_PROC` (default `/proc`) is the test seam. diff --git a/lane-start b/lane-start index 600ecbf..4732f7f 100755 --- a/lane-start +++ b/lane-start @@ -582,8 +582,10 @@ PROJECTS_STATE="${CLAUDE_PROJECTS_DIR:-$CLAUDE_HOME/projects}" # WHICH CLAUDE CODE A LAUNCH STARTS (opensoft/workBenches#119, Brett Heap # 2026-09-29, verbatim "for 2, we can run update on every start, this ensures # we have the latest models"). Decided here, from the environment as it -# arrived, and acted on at the foot of 5a, once the agent and the launch are -# known. Three shapes, and docs/README-claude-current.md has the contract: +# arrived, and acted on by `lane_claude_resolve_launch` below, once a launch +# is certain: at the foot of 5a, and in the bare launch a `--confirm` +# answered No takes. Three shapes, and docs/README-claude-current.md has the +# contract: # * CLAUDE_BIN unset: resolve through `claude-current`, the one `--install` # places beside this command; # * CLAUDE_BIN equal to CLAUDE_RESOLVED_BIN with no CLAUDE_VERIFIED_VERSION: @@ -620,6 +622,60 @@ lane_claude_current() { return 1 } +# lane_claude_resolve_launch: for a run that is to resolve (above) and is about +# to launch, `claude-current --porcelain` names the Claude Code to start. It +# reads npm's published version, updates the user-writable install first when +# every copy is behind, and answers with an absolute path it has run +# `--version` on; CLAUDE_BIN becomes that path, exported with +# CLAUDE_RESOLVED_BIN. Its exit 2 is a refusal (every copy still behind npm) +# and ends this run with 2; any other failure (1, nothing runnable; 64, a +# malformed setting) ends it with 1, the environment status. Both callers come +# before any write: the foot of 5a, where the launcher table's own PATH check +# sits for the same reason, and the bare launch a `--confirm` answered No +# takes. Its stderr is the operator's and goes straight through. Never under +# `--no-launch`, which launches nothing and is the first act INSIDE a running +# session, and never under `--dry-run`, which must not update anything. It +# resolves once: a second call does nothing. +lane_claude_resolve_launch() { + local lcc_cmd lcc_out lcc_rc=0 lcc_line lcc_path="" lcc_version="" lcc_status="" + (( lane_claude_resolve )) || return 0 + (( ! no_launch )) || return 0 + lane_claude_resolve=0 + if ! lcc_cmd="$(lane_claude_current)"; then + note "no claude-current beside this command or in ${OPENREPOTOOLS_BIN_DIR:-$HOME/.local/bin}, so $CLAUDE_BIN is launched as it is (through PATH, where it is a bare name), unchecked against npm; openRepoTools --install places claude-current" + return 0 + fi + if (( dry_run )); then + plan "$lcc_cmd --porcelain (chooses the Claude Code to launch; not run under --dry-run, because it may update one)" + return 0 + fi + lcc_out="$("$lcc_cmd" --porcelain pins one." 2 ;; + *) die "claude-current named no Claude Code to launch (exit $lcc_rc, its reason above). Nothing was written." 1 ;; + esac + while IFS= read -r lcc_line; do + case "$lcc_line" in + path=*) [ -n "$lcc_path" ] || lcc_path="${lcc_line#path=}" ;; + version=*) [ -n "$lcc_version" ] || lcc_version="${lcc_line#version=}" ;; + status=*) [ -n "$lcc_status" ] || lcc_status="${lcc_line#status=}" ;; + esac + done < pins one." 2 ;; - *) die "claude-current named no Claude Code to launch (exit $lcc_rc, its reason above). Nothing was written: the row, the object log and the handoff's Rule 3 stamp all come after this." 1 ;; - esac - lcc_path=""; lcc_version=""; lcc_status="" - while IFS= read -r lcc_line; do - case "$lcc_line" in - path=*) [ -n "$lcc_path" ] || lcc_path="${lcc_line#path=}" ;; - version=*) [ -n "$lcc_version" ] || lcc_version="${lcc_line#version=}" ;; - status=*) [ -n "$lcc_status" ] || lcc_status="${lcc_line#status=}" ;; - esac - done < str: return match.group(0) -@pytest.mark.parametrize("name", ["numcmp", "vercmp"]) +@pytest.mark.parametrize("name", ["numcmp", "precmp", "vercmp", "npm_user_prefix"]) def test_both_commands_order_versions_with_the_same_code(name): - """One idea of "newer" in both commands: a restart notice that disagreed - with the resolver about which version is newer would contradict it.""" + """One idea of "newer" in both commands, and one idea of where the user + npm copy is: a restart notice that disagreed with the resolver about + either would contradict it.""" current = (REPO / "claude-current").read_text(encoding="utf-8") check = (REPO / "claude-restart-check").read_text(encoding="utf-8") assert _function(current, name) == _function(check, name) + + +#: (a, b, vercmp a b). The pre-release rows are SemVer 2.0.0 rule 11's own +#: example chain, 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta +#: < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0, plus the case Copilot +#: raised on #134: beta.9 against beta.10 as strings put beta.9 ahead. +VERSION_ORDER = [ + ("2.1.284", "2.1.284", "0"), + ("2.1.10", "2.1.9", "1"), + ("2.1.9", "2.1.10", "-1"), + ("10.0.0", "9.99.99", "1"), + ("2.1.284-beta.9", "2.1.284-beta.10", "-1"), + ("2.1.284-beta.10", "2.1.284-beta.9", "1"), + ("1.0.0-alpha", "1.0.0-alpha.1", "-1"), + ("1.0.0-alpha.1", "1.0.0-alpha.beta", "-1"), + ("1.0.0-alpha.beta", "1.0.0-beta", "-1"), + ("1.0.0-beta", "1.0.0-beta.2", "-1"), + ("1.0.0-beta.2", "1.0.0-beta.11", "-1"), + ("1.0.0-beta.11", "1.0.0-rc.1", "-1"), + ("1.0.0-rc.1", "1.0.0", "-1"), + ("1.0.0", "1.0.0-rc.1", "1"), + ("1.0.0-Beta", "1.0.0-alpha", "-1"), + ("1.0.0-rc.1+build.5", "1.0.0-rc.1", "0"), +] + + +@pytest.mark.parametrize("a,b,want", VERSION_ORDER) +def test_versions_are_ordered_by_semver(a, b, want): + """vercmp as claude-current carries it, run on its own. Byte order for + the alphanumeric identifiers, so `Beta` sorts before `alpha` in every + locale, which is SemVer's rule and not the locale's.""" + text = (REPO / "claude-current").read_text(encoding="utf-8") + funcs = "".join(_function(text, n) for n in ("numcmp", "precmp", "vercmp")) + result = subprocess.run(["bash", "-c", funcs + 'vercmp "$1" "$2"', "_", a, b], + capture_output=True, text=True, timeout=30, check=False) + assert (result.returncode, result.stdout) == (0, want + "\n"), result.stderr diff --git a/tests/test_claude_restart_check.py b/tests/test_claude_restart_check.py index 1a546c5..f44e695 100644 --- a/tests/test_claude_restart_check.py +++ b/tests/test_claude_restart_check.py @@ -76,7 +76,13 @@ def native(self, *versions: str) -> None: f.chmod(0o755) def run(self, *args: str, **env: str) -> subprocess.CompletedProcess: - full = {k: v for k, v in os.environ.items() if k != "NO_COLOR"} + """The installs are read from `$HOME` and the user npm prefix as + claude-current reads them, so both point into this fixture and every + seam the host exports is dropped.""" + full = {k: v for k, v in os.environ.items() + if k != "NO_COLOR" and not k.startswith(("CLAUDE_", "NPM_CONFIG_", "npm_config_"))} + full["HOME"] = str(self.root / "home") + full["NPM_CONFIG_PREFIX"] = str(self.prefix) full["CLAUDE_RESTART_CHECK_PROC"] = str(self.proc) full.update(env) return subprocess.run([str(CMD), *args], env=full, capture_output=True, @@ -177,6 +183,57 @@ def test_the_walk_starts_at_the_parent_by_default(table): assert result.stdout == restart("2.1.283", "2.1.284") + "\n" +def test_an_npm_session_behind_a_newer_native_install_asks(table): + """Copilot on #134: the installed version was read only from the running + binary's own family, so an npm 2.1.283 session beside a native 2.1.284 + said nothing, and the next launch starts the native one.""" + exe = table.npm_package("2.1.283") + table.native("2.1.284") + status_line_tree(table, str(exe)) + result = table.run("--running", "2.1.283", "--pid", "100", NO_COLOR="1") + assert result.stdout == restart("2.1.283", "2.1.284") + "\n" + + +def test_a_native_session_behind_the_user_npm_copy_asks(table): + table.native("2.1.283") + table.npm_package("2.1.284") + status_line_tree(table, str(table.versions / "2.1.283")) + result = table.run("--pid", "100", NO_COLOR="1") + assert result.stdout == restart("2.1.283", "2.1.284") + "\n" + + +def test_an_older_install_in_the_other_family_is_no_reason(table): + exe = table.npm_package("2.1.284") + table.native("2.1.280") + status_line_tree(table, str(exe)) + assert table.run("--running", "2.1.284", "--pid", "100").stdout == "" + + +def test_the_native_directory_is_the_one_claude_current_reads(table): + exe = table.npm_package("2.1.283") + elsewhere = table.root / "native-elsewhere" + elsewhere.mkdir() + newer = elsewhere / "2.1.290" + newer.write_text("#!/bin/sh\n") + newer.chmod(0o755) + status_line_tree(table, str(exe)) + result = table.run("--running", "2.1.283", "--pid", "100", NO_COLOR="1", + CLAUDE_CURRENT_NATIVE_DIR=str(elsewhere)) + assert result.stdout == restart("2.1.283", "2.1.290") + "\n" + + +def test_no_home_reads_only_the_running_install(table): + exe = table.npm_package("2.1.284") + table.native("2.1.290") + status_line_tree(table, str(exe)) + env = {k: v for k, v in os.environ.items() if k != "HOME"} + env["CLAUDE_RESTART_CHECK_PROC"] = str(table.proc) + result = subprocess.run(["env", "-u", "HOME", str(CMD), "--running", "2.1.284", + "--pid", "100"], env=env, capture_output=True, + text=True, timeout=30, check=False) + assert (result.returncode, result.stdout, result.stderr) == (0, "", "") + + def test_no_claude_above_it_prints_nothing(table): table.process(200, 1, "/usr/bin/tmux") table.process(100, 200, "/usr/bin/dash") diff --git a/tests/test_lane_start_claude_current.py b/tests/test_lane_start_claude_current.py index b4f15a1..9a4f4bd 100644 --- a/tests/test_lane_start_claude_current.py +++ b/tests/test_lane_start_claude_current.py @@ -256,6 +256,14 @@ def _started_line(box: Sandbox) -> str: return lines[0] +def _claude_fields(box: Sandbox) -> list[str]: + """The `claude ` sub-fields of the lane's STARTED payload. Asked + by name and never by position: other amendments append sub-fields of their + own (Amendment 18's `host`, `os` and `container` follow this one).""" + payload = _started_line(box).split(" → ", 1)[1] + return [f for f in payload.split("; ") if f.startswith("claude ")] + + def test_an_unset_claude_bin_launches_what_claude_current_names(box): stub = box.resolver() result = box.start() @@ -274,9 +282,8 @@ def test_the_log_line_records_the_version_claude_current_read(box): box.resolver() result = box.start() assert result.returncode == 0, result.stderr - line = _started_line(box) - assert line.endswith(f"; claude {VERSION}"), line - assert "lane:repoZ-1 → home opensoft/repoZ; " in line, line + assert _claude_fields(box) == [f"claude {VERSION}"], _started_line(box) + assert "lane:repoZ-1 → home opensoft/repoZ; " in _started_line(box) def test_a_refusal_ends_the_run_with_2_before_anything_is_written(box): @@ -323,7 +330,7 @@ def test_a_launchers_verified_version_is_trusted_recorded_and_removed(box): runs = box.claude_runs() assert runs.startswith(f"ran {box.resolved}: --name {LANE}"), runs assert "env CLAUDE_VERIFIED_VERSION=\n" in runs - assert _started_line(box).endswith("; claude 2.1.290") + assert _claude_fields(box) == ["claude 2.1.290"], _started_line(box) def test_an_operator_pin_is_launched_as_it_is_and_records_no_version(box): @@ -333,7 +340,7 @@ def test_an_operator_pin_is_launched_as_it_is_and_records_no_version(box): assert result.returncode == 0, result.stderr assert box.resolver_calls() == "" assert box.claude_runs().startswith(f"ran {pinned}: --name {LANE}") - assert "; claude " not in _started_line(box) + assert _claude_fields(box) == [], _started_line(box) def test_a_version_beside_a_different_resolved_path_is_not_the_pins(box): @@ -347,7 +354,7 @@ def test_a_version_beside_a_different_resolved_path_is_not_the_pins(box): assert result.returncode == 0, result.stderr assert box.resolver_calls() == "" assert "env CLAUDE_VERIFIED_VERSION=\n" in box.claude_runs() - assert "; claude " not in _started_line(box) + assert _claude_fields(box) == [], _started_line(box) def test_an_inherited_resolution_is_resolved_again(box): @@ -359,7 +366,7 @@ def test_an_inherited_resolution_is_resolved_again(box): assert result.returncode == 0, result.stderr assert box.resolver_calls() == f"{stub} --porcelain\n" assert box.claude_runs().startswith(f"ran {box.resolved}: --name {LANE}") - assert _started_line(box).endswith(f"; claude {VERSION}") + assert _claude_fields(box) == [f"claude {VERSION}"], _started_line(box) def test_dry_run_plans_the_resolution_and_runs_nothing(box): @@ -392,7 +399,7 @@ def test_no_resolver_beside_it_launches_the_path_claude_and_says_so(box): assert box.resolver_calls() == "" assert box.claude_runs().startswith(f"ran {box.path_claude}: --name {LANE}") assert "no claude-current beside this command" in result.stderr - assert "; claude " not in _started_line(box) + assert _claude_fields(box) == [], _started_line(box) def test_the_resolver_is_found_in_the_bin_dir_when_not_beside_it(box): @@ -409,7 +416,7 @@ def test_a_version_that_is_not_one_is_left_off_the_log_line(box): result = box.start(FAKE_CC_VERSION="2.1; rm") assert result.returncode == 0, result.stderr assert "is not x.y.z" in result.stderr - assert "; claude " not in _started_line(box) + assert _claude_fields(box) == [], _started_line(box) def test_another_agent_never_asks_claude_current(box): @@ -419,3 +426,28 @@ def test_another_agent_never_asks_claude_current(box): assert result.returncode == 0, result.stderr assert box.resolver_calls() == "" assert box.claude_runs().startswith(f"ran {box.fakebin / 'codex'}: ") + + +def test_a_confirm_answered_no_launches_the_resolved_claude_bare(box): + """Copilot on #134: the bare launch a `--confirm` answered No takes came + before the resolution and started `claude` from PATH. It resolves too.""" + stub = box.resolver() + before = box.commits() + result = box.start("--confirm", LANE_START_ANSWER="n") + assert result.returncode == 0, result.stderr + assert "is NOT taken" in result.stderr + assert box.resolver_calls() == f"{stub} --porcelain\n" + runs = box.claude_runs() + assert runs.startswith(f"ran {box.resolved}: \n"), runs + assert f"env CLAUDE_RESOLVED_BIN={box.resolved}\n" in runs + assert box.commits() == before + + +def test_a_confirm_answered_no_is_refused_when_stale(box): + box.resolver() + before = box.commits() + result = box.start("--confirm", LANE_START_ANSWER="n", FAKE_CC_RC="2") + assert result.returncode == 2, result.stderr + assert "claude-current refused the launch (exit 2" in result.stderr + assert box.claude_runs() == "" + assert box.commits() == before From 9ba858d7378b449cf25114895b6ca8628d39380c Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:09:38 +0000 Subject: [PATCH 07/18] Copilot's third round on #134: the lock without flock names its owner as it is taken, and the two commands borrow every per-file hygiene rule - Without `flock`, the lock was a directory made first and given its owner's pid a line later. A launch killed between the two left a lock with no owner, which the takeover never removes (it reaps only a named dead owner), so every later launch waited it out and refused. The lock is now a symlink whose text is `pid=`: one `ln -s` makes it and names the owner, so no ownerless lock can exist. The reaper is unchanged. A directory standing at the lock path (where `ln` would put the link inside it and succeed) is refused by name. The release removes the link only while it still names this launch. - The two commands are still not in test_repo_hygiene.py's `ALL_BASH`, because that file is being changed by open PR #93 and this PR touches none of #93's files. tests/test_claude_current.py now also borrows the flag-spelling rule and runs the LF-index check for both. The follow-up after #93 lands is to put `claude-current` in `SHIPPED_BASH` and `claude-restart-check` in `HELPER_BASH`, then drop the borrowed list. tests/test_claude_current.py, 72 cases: - `test_the_link_lock_names_its_owner_while_it_is_held` reads the lock from inside the update. It fails on the directory lock. - `test_a_directory_at_the_lock_path_is_refused_not_taken` is new. - The takeover, reaper and live-lock cases now work on the link. Refs opensoft/workBenches#119 Lane: openxfactory-5 (openXfactory-5) Co-Authored-By: Claude Opus 5.5 --- claude-current | 41 +++++++++------ docs/README-claude-current.md | 15 +++--- tests/test_claude_current.py | 95 +++++++++++++++++++++++++++-------- 3 files changed, 108 insertions(+), 43 deletions(-) diff --git a/claude-current b/claude-current index 6184508..6609017 100755 --- a/claude-current +++ b/claude-current @@ -95,7 +95,7 @@ # # Bash 3.2 safe (macOS): no mapfile, no associative arrays, no readlink -f, # no GNU-only flags; `timeout`, else `gtimeout`, else a watchdog; `flock`, else -# a mkdir lock. +# a symlink lock. set -euo pipefail @@ -451,7 +451,7 @@ EOF # ------------------------------------------------------------ the lock lock_fd_open=0 -mkdir_lock="" +link_lock="" lock_why="" # take_lock: 0 with the lock held, or 1 with lock_why saying why not. @@ -476,8 +476,11 @@ take_lock() { lock_fd_open=0 return 1 fi - # No flock on a stock macOS: a mkdir lock whose owner writes its pid, so a - # lock left by a process that died is taken over, not waited out. + # No flock on a stock macOS: the lock is a SYMLINK whose text names its + # owner, `pid=`. `ln -s` makes it and publishes the owner in one + # atomic act, so a launch killed at any point leaves either no lock or a + # lock that names it, never an ownerless one every later launch would wait + # on. A lock whose owner is no longer running is taken over, not waited out. # # ONE LAUNCH REMOVES A DEAD OWNER'S LOCK, and it holds a second directory, # the reaper, while it looks again. Two launches that both read the same @@ -486,14 +489,23 @@ take_lock() { # update at once. Under the reaper the lock is removed only while it still # names the pid that was seen dead. A reaper left by a launch killed in # those few lines blocks the takeover, and the refusal names it. - d="$LOCK_FILE.d" - while ! mkdir -- "$d" 2>/dev/null; do - owner="$(cat -- "$d/pid" 2>/dev/null || :)" + d="$LOCK_FILE.l" + while :; do + if ln -s "pid=$$" "$d" 2>/dev/null; then + [ "$(readlink -- "$d" 2>/dev/null || :)" = "pid=$$" ] && break + # A directory at that path: `ln` put the link inside it. + rm -f -- "$d/pid=$$" 2>/dev/null || : + lock_why="$d is a directory and not this command's lock: remove it" + return 1 + fi + owner="$(readlink -- "$d" 2>/dev/null || :)" + owner="${owner#pid=}" + case "$owner" in '' | *[!0-9]*) owner="" ;; esac if [ -n "$owner" ] && ! kill -0 "$owner" 2>/dev/null && mkdir -- "$d.reap" 2>/dev/null; then reaped=0 - if [ "$(cat -- "$d/pid" 2>/dev/null || :)" = "$owner" ] && - rm -rf -- "$d" 2>/dev/null && [ ! -e "$d" ]; then + if [ "$(readlink -- "$d" 2>/dev/null || :)" = "pid=$owner" ] && + rm -f -- "$d" 2>/dev/null && [ ! -L "$d" ]; then reaped=1 fi rmdir -- "$d.reap" 2>/dev/null || : @@ -509,8 +521,7 @@ take_lock() { sleep 1 waited=$((waited + 1)) done - printf '%s\n' "$$" >"$d/pid" - mkdir_lock="$d" + link_lock="$d" lock_why="" return 0 } @@ -521,9 +532,11 @@ release_lock() { exec 9>&- lock_fd_open=0 fi - if [ -n "$mkdir_lock" ]; then - rm -rf -- "$mkdir_lock" - mkdir_lock="" + if [ -n "$link_lock" ]; then + if [ "$(readlink -- "$link_lock" 2>/dev/null || :)" = "pid=$$" ]; then + rm -f -- "$link_lock" + fi + link_lock="" fi } trap release_lock EXIT diff --git a/docs/README-claude-current.md b/docs/README-claude-current.md index 1988b86..a9f2cd4 100644 --- a/docs/README-claude-current.md +++ b/docs/README-claude-current.md @@ -71,15 +71,16 @@ order. Build metadata after a `+` is ignored. **The update runs under a lock** at `${XDG_CACHE_HOME:-~/.cache}/openrepotools/claude-current.lock`: `flock` where -it exists, else a directory lock that records its owner's pid, so a lock left -by a process that died is taken over, by one launch only: the one that holds -a second directory, the reaper, while it reads the pid again. After it takes -the lock it reads the candidates again, because another launch may have -updated while it waited. A lock it cannot take within +it exists, else a symlink whose text names its owner (`pid=`). One +`ln -s` makes that lock and names its owner, so no lock is ever left without +one. A lock whose owner has died is taken over, by one launch only: the one +that holds a second directory, the reaper, while it reads the owner again. +After it takes the lock it reads the candidates again, because another launch +may have updated while it waited. A lock it cannot take within `CLAUDE_CURRENT_LOCK_WAIT` seconds is a refusal, unless that other launch's update has already produced npm's version. A native install gets -`claude update`. When that does not reach npm's version, -or there is no native install, it runs +`claude update`. When that does not reach npm's version, or there is no +native install, it runs `npm install -g --prefix @anthropic-ai/claude-code@`. If `--version` still differs after that, it runs the package's own `install.cjs` with `node`. npm 12's install-script policy skips that hook, so diff --git a/tests/test_claude_current.py b/tests/test_claude_current.py index 83615f4..26e956e 100644 --- a/tests/test_claude_current.py +++ b/tests/test_claude_current.py @@ -61,6 +61,8 @@ #: launcher linked from `

/bin/claude` into `lib/node_modules` — and, with #: `$FAKE_NPM_HOOK_NEEDED=1`, leaves the launcher a stub until the package's #: `install.cjs` has run, which is npm 12's skipped native-binary hook. +#: `$FAKE_NPM_INSTALL_HOOK` is shell run at the start of an install, while the +#: launch that runs it holds the update lock. FAKE_NPM = r"""#!/usr/bin/env bash printf '%s\n' "$*" >> "${FAKE_NPM_LOG:-/dev/null}" case "${1:-}" in @@ -70,6 +72,7 @@ printf '%s\n' "${FAKE_NPM_PUBLISHED:-}" ;; install) [ "${FAKE_NPM_INSTALL_RC:-0}" = 0 ] || { echo "npm ERR! fake failure" >&2; exit "$FAKE_NPM_INSTALL_RC"; } + if [ -n "${FAKE_NPM_INSTALL_HOOK:-}" ]; then eval "$FAKE_NPM_INSTALL_HOOK"; fi prefix=""; spec="" while [ $# -gt 0 ]; do case "$1" in @@ -339,8 +342,8 @@ def _hold_lock(box: Sandbox, seconds: int, then: str = ":") -> subprocess.Popen: if shutil.which("flock"): script = f'exec 9>>"{lock}"; flock 9; touch "{box.root}/held"; sleep {seconds}; {then}' else: - script = (f'mkdir "{lock}.d"; echo $$ > "{lock}.d/pid"; touch "{box.root}/held"; ' - f'sleep {seconds}; {then}; rm -rf "{lock}.d"') + script = (f'ln -s "pid=$$" "{lock}.l"; touch "{box.root}/held"; ' + f'sleep {seconds}; {then}; rm -f "{lock}.l"') proc = subprocess.Popen(["bash", "-c", script], env=box.env()) deadline = time.monotonic() + 10 while not (box.root / "held").exists(): @@ -497,20 +500,56 @@ def _dead_pid() -> int: return proc.pid -def test_a_mkdir_lock_whose_owner_died_is_taken_over(box): - """No `flock`: the lock is a directory holding its owner's pid, and a pid +def _link_lock(box: Sandbox, pid: int) -> Path: + """The lock as a launch without `flock` makes it: a symlink whose text + names its owner.""" + lock = box.cache / "claude-current.lock.l" + lock.symlink_to(f"pid={pid}") + return lock + + +def test_a_link_lock_whose_owner_died_is_taken_over(box): + """No `flock`: the lock is a symlink whose text names its owner, and a pid that is no longer running is a lock nobody holds.""" box.user_copy("2.1.283") - lock = box.cache / "claude-current.lock.d" - lock.mkdir() - (lock / "pid").write_text(f"{_dead_pid()}\n") + lock = _link_lock(box, _dead_pid()) result = box.run("--porcelain", CLAUDE_CURRENT_LOCK_WAIT="5", PATH=path_without(box, "flock")) assert result.returncode == 0, result.stderr assert porcelain(result)["status"] == "verified" assert len(box.installs()) == 1 - assert not lock.exists(), "the lock was not released" - assert not (box.cache / "claude-current.lock.d.reap").exists() + assert not lock.is_symlink(), "the lock was not released" + assert not (box.cache / "claude-current.lock.l.reap").exists() + + +def test_the_link_lock_names_its_owner_while_it_is_held(box): + """Copilot on #134: a directory lock was made first and given its owner's + pid a line later, so a launch killed between the two left a lock with no + owner, which every later launch waited on and refused. `ln -s` makes the + lock and names the owner in one act. Observed from inside the update: the + fake npm reads the lock while the launch that holds it is installing.""" + box.user_copy("2.1.283") + seen = box.root / "seen-lock" + lock = box.cache / "claude-current.lock.l" + result = box.run("--porcelain", PATH=path_without(box, "flock"), + FAKE_NPM_INSTALL_HOOK=f'readlink "{lock}" > "{seen}"') + assert result.returncode == 0, result.stderr + assert seen.read_text().startswith("pid="), seen.read_text() + assert int(seen.read_text().strip()[4:]) > 0 + assert not lock.is_symlink(), "the lock was not released" + + +def test_a_directory_at_the_lock_path_is_refused_not_taken(box): + """`ln -s` onto a directory puts the link inside it and succeeds, which + would be a lock this launch believes it holds and nobody else can see.""" + box.user_copy("2.1.283") + stray = box.cache / "claude-current.lock.l" + stray.mkdir() + result = box.run(CLAUDE_CURRENT_LOCK_WAIT="1", PATH=path_without(box, "flock")) + assert result.returncode == 2, result.stderr + assert f"{stray} is a directory and not this command's lock" in result.stderr + assert list(stray.iterdir()) == [], "the link it made inside was left behind" + assert box.installs() == [] def test_a_dead_owners_lock_another_launch_is_reaping_is_left_to_it(box): @@ -519,27 +558,23 @@ def test_a_dead_owners_lock_another_launch_is_reaping_is_left_to_it(box): Only the launch holding the reaper directory removes it, so a launch that finds the reaper taken leaves the lock alone and waits.""" box.user_copy("2.1.283") - lock = box.cache / "claude-current.lock.d" - lock.mkdir() - (lock / "pid").write_text(f"{_dead_pid()}\n") - reaper = box.cache / "claude-current.lock.d.reap" + lock = _link_lock(box, _dead_pid()) + reaper = box.cache / "claude-current.lock.l.reap" reaper.mkdir() result = box.run(CLAUDE_CURRENT_LOCK_WAIT="1", PATH=path_without(box, "flock")) assert result.returncode == 2, result.stderr - assert lock.is_dir(), "a launch removed a lock another launch was reaping" + assert lock.is_symlink(), "a launch removed a lock another launch was reaping" assert "was not free within 1s" in result.stderr assert f"{reaper}, the directory a launch holds while it removes a dead owner's lock" in result.stderr assert box.installs() == [] -def test_a_live_mkdir_lock_is_waited_on_and_never_removed(box): +def test_a_live_link_lock_is_waited_on_and_never_removed(box): box.user_copy("2.1.283") - lock = box.cache / "claude-current.lock.d" - lock.mkdir() - (lock / "pid").write_text(f"{os.getpid()}\n") + lock = _link_lock(box, os.getpid()) result = box.run(CLAUDE_CURRENT_LOCK_WAIT="1", PATH=path_without(box, "flock")) assert result.returncode == 2, result.stderr - assert (lock / "pid").read_text() == f"{os.getpid()}\n" + assert os.readlink(lock) == f"pid={os.getpid()}" assert "was not free within 1s" in result.stderr assert "the directory a launch holds" not in result.stderr assert box.installs() == [] @@ -610,11 +645,15 @@ def test_a_malformed_bound_is_refused_before_anything_runs(box, name): # --- the file itself ------------------------------------------------------------ #: The rules `tests/test_repo_hygiene.py` holds every listed bash file to, -#: asked of the two #119 commands, which its lists do not name yet (that file -#: is another open pull request's, opensoft/openRepoTools#93, so this one -#: calls its rules rather than editing its lists). +#: asked of the two #119 commands, which its lists do not name yet. That file +#: is being changed by another open pull request, opensoft/openRepoTools#93, +#: so this one calls its per-file rules rather than editing its lists. Once #93 +#: lands, `claude-current` belongs in its `SHIPPED_BASH` (`set -euo pipefail`) +#: and `claude-restart-check` in its `HELPER_BASH` (`set -u`, no workspace +#: resolver), and this list and the LF check below go (Copilot on #134). HYGIENE_RULES = [ hygiene.test_shipped_bash_parses_under_bash, + hygiene.test_the_two_spellings_of_a_flag_refuse_the_same_empty_value, hygiene.test_no_shipped_bash_ends_its_options_after_an_operand, hygiene.test_no_shipped_bash_leaves_a_variable_name_to_bash_3_2s_locale, hygiene.test_no_shipped_bash_quotes_the_replacement_half_of_a_substitution, @@ -644,6 +683,18 @@ def test_claude_current_fails_loudly_and_claude_restart_check_never_does(): "own 'print nothing, exit 0', not end the status line's render") +def test_both_commands_are_tracked_with_lf_in_the_index(): + """`test_every_shipped_bash_file_is_tracked_with_lf`, which reads its + names from `ALL_BASH`, asked of these two.""" + proc = subprocess.run(["git", "ls-files", "--eol", "--", "claude-current", + "claude-restart-check"], cwd=str(REPO), + capture_output=True, text=True, check=True) + rows = proc.stdout.splitlines() + assert len(rows) == 2, proc.stdout + for row in rows: + assert "i/lf" in row, f"not LF in the index:\n {row}" + + def test_the_macos_job_parses_both_commands_with_bash_3_2(): workflow = (REPO / ".github" / "workflows" / "tests.yml").read_text(encoding="utf-8") for name in ("claude-current", "claude-restart-check"): From c7e3e3c4b99a37a90878354ad0c4c517c8f3b8a4 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:17:30 +0000 Subject: [PATCH 08/18] claude-current: a copy ahead of npm that appears during the lock wait is ahead, not stale Copilot's overview at 9ba858d, a finding in code unchanged since its earlier rounds: after the wait for another launch's update, only a copy EQUAL to npm was accepted. A copy newer than the published version that the other launch installed meanwhile (npm moved on while this launch waited) therefore went to another update, or, when the wait ran out, to the stale refusal, although the same copy is `ahead` before any wait. The re-read after the lock now gives the same two answers as the first read, in the same order. test_a_copy_ahead_of_npm_that_appears_during_the_wait_is_ahead: the other launch installs 2.1.285 against a published 2.1.284 while it holds the lock past this launch's wait. At 9ba858d this is exit 2; here it is `ahead`, and this launch installs nothing. 73 cases. Refs opensoft/workBenches#119 Lane: openxfactory-5 (openXfactory-5) Co-Authored-By: Claude Opus 5.5 --- claude-current | 7 +++++++ tests/test_claude_current.py | 26 +++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/claude-current b/claude-current index 6609017..e64c7bf 100755 --- a/claude-current +++ b/claude-current @@ -655,6 +655,13 @@ if pick equal "$PUBLISHED"; then release_lock answer verified "claude $CHOSEN_VERSION (verified against npm $PUBLISHED) at $CHOSEN_PATH, updated by another launch" fi +# The same two answers as before the wait, in the same order: a copy newer +# than npm that appeared while this launch waited is `ahead` here too, and not +# a reason to update again or to refuse (Copilot on #134). +if pick ahead "$PUBLISHED"; then + release_lock + answer ahead "claude $CHOSEN_VERSION (ahead of npm $PUBLISHED) at $CHOSEN_PATH, updated by another launch" +fi if [ "$locked" = 1 ]; then if [ -n "$native_path" ]; then update_native || : diff --git a/tests/test_claude_current.py b/tests/test_claude_current.py index 26e956e..92e2b22 100644 --- a/tests/test_claude_current.py +++ b/tests/test_claude_current.py @@ -336,7 +336,7 @@ def test_nothing_installed_is_installed_into_the_user_prefix(box): assert (fields["path"], fields["status"]) == (str(box.prefix / "bin" / "claude"), "verified") -def _hold_lock(box: Sandbox, seconds: int, then: str = ":") -> subprocess.Popen: +def _hold_lock(box: Sandbox, seconds: float, then: str = ":") -> subprocess.Popen: """Hold the update lock the way a second launch would, then run `then`.""" lock = box.cache / "claude-current.lock" if shutil.which("flock"): @@ -367,6 +367,30 @@ def test_a_launch_waits_for_another_launchs_update_and_does_not_repeat_it(box): assert len(box.installs()) == 1, "only the other launch installed" +def test_a_copy_ahead_of_npm_that_appears_during_the_wait_is_ahead(box): + """Copilot on #134: after the wait only an EQUAL copy was accepted, so a + copy newer than npm that another launch installed meanwhile (npm moved on + while this launch waited) reached the stale refusal, although the same + copy is `ahead` before any wait. The other launch takes the lock, installs + 2.1.285 a second and a half later, and still holds the lock when this + launch's four-second wait runs out.""" + box.user_copy("2.1.283") + installer = (f'"{box.bin / "npm"}" install -g --prefix "{box.prefix}" ' + f'{PACKAGE}@2.1.285 >/dev/null 2>&1; sleep 8') + holder = _hold_lock(box, 1.5, then=installer) + try: + result = box.run("--porcelain", CLAUDE_CURRENT_LOCK_WAIT="4") + finally: + holder.kill() + holder.wait(timeout=30) + assert result.returncode == 0, result.stderr + assert porcelain(result)["status"] == "ahead" + assert porcelain(result)["version"] == "2.1.285" + assert "(ahead of npm 2.1.284)" in result.stderr + assert "updated by another launch" in result.stderr + assert len(box.installs()) == 1, "only the other launch installed" + + def test_a_lock_that_is_never_free_is_a_refusal_that_names_it(box): box.user_copy("2.1.283") holder = _hold_lock(box, 8) From da83af3c05fffba815a93e760f587a5a926e72fd Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:24:24 +0000 Subject: [PATCH 09/18] claude-current and claude-restart-check: a relative npm prefix is taken under $HOME Copilot's overview at c7e3e3c, a finding in code unchanged since earlier rounds: npm takes a relative `--prefix` and installs there, but the candidate read back from it is not an absolute path and is refused, so an update that worked ended in "no runnable Claude Code" (exit 1). A relative prefix from any source ($CLAUDE_CURRENT_NPM_PREFIX, $NPM_CONFIG_PREFIX, $npm_config_prefix, the `prefix=` line of ~/.npmrc) is now anchored at $HOME, in `npm_user_prefix`, which both commands carry byte for byte. npm installs into that path, the resolver reads it back, and the restart check reads it from whatever directory the status line runs in. Both suites now run their command from inside the sandbox rather than the checkout, so a relative path can never land in the repository. On the previous head the three new cases fail: the resolver exits 1 for a relative prefix from the environment and from ~/.npmrc, and the restart check never reads the copy. They pass here. Suites: tests/test_claude_current.py 78, tests/test_claude_restart_check.py 20. Refs opensoft/workBenches#119 Lane: openxfactory-5 (openXfactory-5) Co-Authored-By: Claude Opus 5.5 --- claude-current | 9 ++++++++- claude-restart-check | 6 ++++++ docs/README-claude-current.md | 3 ++- tests/test_claude_current.py | 24 ++++++++++++++++++++++-- tests/test_claude_restart_check.py | 17 +++++++++++++++-- 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/claude-current b/claude-current index e64c7bf..b768a94 100755 --- a/claude-current +++ b/claude-current @@ -29,7 +29,8 @@ # ~/.local/share/claude/versions), by its x.y.z name; # - /bin/claude. The prefix is $CLAUDE_CURRENT_NPM_PREFIX, # else $NPM_CONFIG_PREFIX, else $npm_config_prefix, else the -# `prefix=` line of ~/.npmrc, else ~/.npm-global; +# `prefix=` line of ~/.npmrc, else ~/.npm-global. A relative one is +# taken under $HOME; # - ~/.local/bin/claude; # - each entry of $CLAUDE_CURRENT_SYSTEM_CANDIDATES (default # /usr/local/bin/claude:/usr/bin/claude, the image's copies). @@ -343,6 +344,12 @@ npm_user_prefix() { esac p="${p%/}" [ -n "$p" ] || p="$HOME/.npm-global" + # A relative prefix is anchored at $HOME, so the path npm installs into is + # the path read back, and the same one whichever directory asks. + case "$p" in + /*) ;; + *) p="$HOME/$p" ;; + esac printf '%s\n' "$p" } USER_PREFIX="$(npm_user_prefix)" diff --git a/claude-restart-check b/claude-restart-check index 835d97d..14184df 100755 --- a/claude-restart-check +++ b/claude-restart-check @@ -252,6 +252,12 @@ npm_user_prefix() { esac p="${p%/}" [ -n "$p" ] || p="$HOME/.npm-global" + # A relative prefix is anchored at $HOME, so the path npm installs into is + # the path read back, and the same one whichever directory asks. + case "$p" in + /*) ;; + *) p="$HOME/$p" ;; + esac printf '%s\n' "$p" } diff --git a/docs/README-claude-current.md b/docs/README-claude-current.md index a9f2cd4..8efb832 100644 --- a/docs/README-claude-current.md +++ b/docs/README-claude-current.md @@ -42,7 +42,8 @@ absolute path, in this order, and runs each one's `--version`: x.y.z name (workBenches #109's ordering); 2. `/bin/claude`. The prefix is `$CLAUDE_CURRENT_NPM_PREFIX`, else `$NPM_CONFIG_PREFIX`, else `$npm_config_prefix`, else the `prefix=` - line of `~/.npmrc`, else `~/.npm-global`; + line of `~/.npmrc`, else `~/.npm-global`. A relative prefix is taken under + `$HOME`, so the path npm installs into is the path read back; 3. `~/.local/bin/claude`; 4. the image's copies, `/usr/local/bin/claude` and `/usr/bin/claude` (`$CLAUDE_CURRENT_SYSTEM_CANDIDATES`, colon-separated; set it empty for diff --git a/tests/test_claude_current.py b/tests/test_claude_current.py index 92e2b22..932f7db 100644 --- a/tests/test_claude_current.py +++ b/tests/test_claude_current.py @@ -184,9 +184,10 @@ def env(self, **extra: str) -> dict: return env def run(self, *args: str, timeout: float = 60, **extra: str) -> subprocess.CompletedProcess: + """From inside the sandbox, so nothing relative lands in the checkout.""" return subprocess.run([str(CMD), *args], env=self.env(**extra), - capture_output=True, text=True, timeout=timeout, - check=False) + cwd=str(self.root), capture_output=True, text=True, + timeout=timeout, check=False) def installs(self) -> list[str]: return [line for line in self.npm_log.read_text().splitlines() @@ -645,6 +646,25 @@ def test_the_user_prefix_is_read_from_the_npmrc_prefix_line(box): assert porcelain(result)["path"] == str(box.home / "tools" / "npm" / "bin" / "claude") +@pytest.mark.parametrize("where", ["environment", ".npmrc"]) +def test_a_relative_user_prefix_is_taken_under_home(box, where): + """Copilot on #134: npm takes a relative `--prefix` and installs there, and + the candidate read back was then refused as not absolute, so the update + worked and the launch exited 1. Both commands anchor it at $HOME.""" + env = {"CLAUDE_CURRENT_NPM_PREFIX": ""} + if where == "environment": + env["NPM_CONFIG_PREFIX"] = "rel-prefix" + else: + (box.home / ".npmrc").write_text("prefix=rel-prefix\n") + result = box.run("--porcelain", **env) + assert result.returncode == 0, result.stderr + fields = porcelain(result) + assert (fields["path"], fields["status"]) == ( + str(box.home / "rel-prefix" / "bin" / "claude"), "verified") + assert box.installs() == [ + f"install -g --prefix {box.home / 'rel-prefix'} --no-fund --no-audit {PACKAGE}@2.1.284"] + + # --- usage -------------------------------------------------------------------- def test_help_and_an_unknown_argument(box): diff --git a/tests/test_claude_restart_check.py b/tests/test_claude_restart_check.py index f44e695..536f474 100644 --- a/tests/test_claude_restart_check.py +++ b/tests/test_claude_restart_check.py @@ -85,8 +85,9 @@ def run(self, *args: str, **env: str) -> subprocess.CompletedProcess: full["NPM_CONFIG_PREFIX"] = str(self.prefix) full["CLAUDE_RESTART_CHECK_PROC"] = str(self.proc) full.update(env) - return subprocess.run([str(CMD), *args], env=full, capture_output=True, - text=True, timeout=30, check=False) + return subprocess.run([str(CMD), *args], env=full, cwd=str(self.root), + capture_output=True, text=True, timeout=30, + check=False) @pytest.fixture @@ -222,6 +223,18 @@ def test_the_native_directory_is_the_one_claude_current_reads(table): assert result.stdout == restart("2.1.283", "2.1.290") + "\n" +def test_a_relative_user_prefix_is_read_under_home(table): + """The same anchor claude-current installs under (Copilot on #134).""" + table.native("2.1.283") + pkg = (table.root / "home" / "rel-prefix" / "lib" / "node_modules" + / "@anthropic-ai" / "claude-code") + pkg.mkdir(parents=True) + (pkg / "package.json").write_text('{\n "version": "2.1.284"\n}\n') + status_line_tree(table, str(table.versions / "2.1.283")) + result = table.run("--pid", "100", NO_COLOR="1", NPM_CONFIG_PREFIX="rel-prefix") + assert result.stdout == restart("2.1.283", "2.1.284") + "\n" + + def test_no_home_reads_only_the_running_install(table): exe = table.npm_package("2.1.284") table.native("2.1.290") From b90f4dc5cc1312408750ab819020f71c790169bc Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:36:19 +0000 Subject: [PATCH 10/18] Copilot's overview at da83af3: KILL after a grace on every bound, a minified package.json, and one count Three findings the overview lists as missed in unchanged code, all real: - A command that ignored TERM, with its children, held a launch past every bound, because TERM was the only signal. Every branch now sends KILL KILL_GRACE (5) seconds after TERM: `timeout -k` and `gtimeout -k`, and in the watchdog, the whole tree it signalled. The watchdog and run_bounded settle who acts with one `mkdir` of a flag, whoever makes it first, so a command that ends early is never signalled and a watchdog is never stopped half way. A zombie counts as ended, because in a container whose first process reaps nothing (py-bench's pid 1 is `sleep`) it is never reaped. A 137 from the KILL reads as the timeout's 124. - claude-restart-check read `"version"` only from a line of its own, so a minified package.json gave no installed version and no notice. The file is cut at each brace and comma before the first "version" is taken, and a missing or unreadable file is still silent. - The comment above `place_skill_and_hook` said "thirteen placements" for fourteen artifacts. It now says fourteen artifacts in thirteen writes (the two hook entries go into settings.json in one write). On da83af3 the three new cases fail (the TERM-ignoring npm on both branches, and the minified package); they pass here. Suites: tests/test_claude_current.py 80, tests/test_claude_restart_check.py 21. Refs opensoft/workBenches#119 Lane: openxfactory-5 (openXfactory-5) Co-Authored-By: Claude Opus 5.5 --- claude-current | 97 +++++++++++++++++++++--------- claude-restart-check | 7 ++- docs/README-claude-current.md | 3 +- openRepoTools | 4 +- tests/test_claude_current.py | 21 +++++++ tests/test_claude_restart_check.py | 13 ++++ 6 files changed, 115 insertions(+), 30 deletions(-) diff --git a/claude-current b/claude-current index b768a94..6c132d8 100755 --- a/claude-current +++ b/claude-current @@ -173,46 +173,89 @@ is_version() { [[ "${1:-}" =~ $version_re ]]; } # ------------------------------------------------------------ small helpers -# kill_tree : the process and every process below it. Each one is stopped -# before its children are listed, so none of them can start another while the -# tree is walked, then sent TERM and continued so that it can act on it. -# `pgrep -P` is on Linux and macOS alike; without it only is signalled. +# Seconds a command that ignores TERM is given before it is sent KILL, so a +# bound is a bound whatever the command does with the first signal. +KILL_GRACE=5 + +# kill_tree []: the process and every process below it, and the +# pids it signalled, one per line on stdout. Each one is stopped before its +# children are listed, so none of them can start another while the tree is +# walked, then sent (default TERM) and continued so that it can act +# on it. `pgrep -P` is on Linux and macOS alike; without it only is +# signalled. kill_tree() { - local child + local child sig="${2:-TERM}" kill -STOP "$1" 2>/dev/null || return 0 + printf '%s\n' "$1" for child in $(pgrep -P "$1" 2>/dev/null); do - kill_tree "$child" + kill_tree "$child" "$sig" done - kill -TERM "$1" 2>/dev/null || : + kill -"$sig" "$1" 2>/dev/null || : kill -CONT "$1" 2>/dev/null || : } +# running : 0 while has not exited. A zombie has exited and waits +# only to be reaped, which in a container whose first process reaps nothing +# can be for ever, so it is not running. Without `ps`, a pid that answers +# `kill -0` is taken as running. +running() { + local st + kill -0 "$1" 2>/dev/null || return 1 + st="$(ps -o stat= -p "$1" 2>/dev/null)" || return 0 + case "$st" in *Z*) return 1 ;; esac + return 0 +} + # run_bounded [args...]: the command's own status, or 124 # when it ran out of time. GNU `timeout`, else Homebrew's `gtimeout`, else a -# watchdog, because a stock macOS ships neither. The watchdog ends the WHOLE -# tree, as `timeout` ends its process group: a command's own children (npm's -# lifecycle processes, a script's `sleep`) would otherwise keep the caller's -# command substitution open after the command itself was killed. The -# watchdog's own output goes to /dev/null for the same reason. +# watchdog, because a stock macOS ships neither. Every branch sends TERM at +# the bound and KILL $KILL_GRACE seconds later to what is still running: +# `-k` for the two commands, and for the watchdog the whole tree it signalled. +# The watchdog ends the WHOLE tree, as `timeout` ends its process group: a +# command's own children (npm's lifecycle processes, a script's `sleep`) +# would otherwise keep the caller's command substitution open after the +# command itself was killed. The watchdog's own output goes to /dev/null for +# the same reason. run_bounded() { - local secs="$1" pid dog rc=0 + local secs="$1" pid dog rc=0 flag shift if command -v timeout >/dev/null 2>&1; then - timeout "$secs" "$@" || rc=$? - return "$rc" - fi - if command -v gtimeout >/dev/null 2>&1; then - gtimeout "$secs" "$@" || rc=$? - return "$rc" + timeout -k "$KILL_GRACE" "$secs" "$@" || rc=$? + elif command -v gtimeout >/dev/null 2>&1; then + gtimeout -k "$KILL_GRACE" "$secs" "$@" || rc=$? + else + # WHOEVER MAKES $flag FIRST DECIDES: the watchdog, when the time is up, + # or this function, when the command ended first. So this function + # never kills a watchdog half way through its TERM and KILL, and a + # watchdog that wakes after the command ended signals nothing. Where no + # flag can be made at all, the watchdog acts and this function stops it. + flag="${TMPDIR:-/tmp}/claude-current.bound.$$.$RANDOM" + "$@" & + pid=$! + ( + sleep "$secs" + if ! mkdir -- "$flag" 2>/dev/null && [ -d "$flag" ]; then exit 0; fi + tree="$(kill_tree "$pid")" + n=0 + while [ "$n" -lt "$KILL_GRACE" ]; do + alive="" + for p in $tree; do running "$p" && alive=1; done + [ -n "$alive" ] || exit 0 + sleep 1 + n=$((n + 1)) + done + for p in $tree; do kill -KILL "$p" 2>/dev/null || :; done + ) >/dev/null 2>&1 & + dog=$! + wait "$pid" || rc=$? + if mkdir -- "$flag" 2>/dev/null || [ ! -d "$flag" ]; then + kill_tree "$dog" >/dev/null + fi + wait "$dog" 2>/dev/null || : + rmdir -- "$flag" 2>/dev/null || : + [ "$rc" = 143 ] && rc=124 fi - "$@" & - pid=$! - ( sleep "$secs"; kill_tree "$pid" ) >/dev/null 2>&1 & - dog=$! - wait "$pid" || rc=$? - kill_tree "$dog" - wait "$dog" 2>/dev/null || : - [ "$rc" = 143 ] && rc=124 + [ "$rc" = 137 ] && rc=124 return "$rc" } diff --git a/claude-restart-check b/claude-restart-check index 14184df..68d2ee5 100755 --- a/claude-restart-check +++ b/claude-restart-check @@ -224,8 +224,13 @@ highest_in() { printf '%s' "$best" } +# package_version : its first "version" value, however the file +# is laid out. npm writes it one key to a line; a minified one has every key +# on one line, so the file is cut at each brace and comma first. package_version() { - sed -n -e 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*$/\1/p' "$1" 2>/dev/null | head -n 1 + [ -r "$1" ] || return 0 + tr '{},' '\n\n\n' 2>/dev/null <"$1" | + sed -n -e 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*$/\1/p' | head -n 1 } # The user npm prefix, found the way claude-current finds it (byte for byte), diff --git a/docs/README-claude-current.md b/docs/README-claude-current.md index 8efb832..3bf548a 100644 --- a/docs/README-claude-current.md +++ b/docs/README-claude-current.md @@ -123,7 +123,8 @@ apart from 1 because a launcher reports 1 as "no Claude Code". The timeouts use `timeout`, else Homebrew's `gtimeout`, else a watchdog of its own, because a stock macOS ships neither. The watchdog ends the command's whole process tree, as `timeout` ends its process group, so a child npm started -cannot hold the answer open past the bound. +cannot hold the answer open past the bound. On every branch a command that +ignores TERM is sent KILL five seconds later. ## The hand-off diff --git a/openRepoTools b/openRepoTools index dca95f8..1c3ab27 100755 --- a/openRepoTools +++ b/openRepoTools @@ -1207,7 +1207,9 @@ $(guard_block_text)" fi } -# The thirteen placements, AFTER the fifteen files and the merge are all in hand. +# The fourteen artifacts that are not bin-directory files, in thirteen writes +# (the two hook entries go into settings.json in one), AFTER the fifteen files +# and the merge are all in hand. place_skill_and_hook() { local shared_dir bare_dir settings target verb hook_tmp name settings="$(claude_home)/settings.json" diff --git a/tests/test_claude_current.py b/tests/test_claude_current.py index 932f7db..5e64c2b 100644 --- a/tests/test_claude_current.py +++ b/tests/test_claude_current.py @@ -67,6 +67,7 @@ printf '%s\n' "$*" >> "${FAKE_NPM_LOG:-/dev/null}" case "${1:-}" in view) + if [ -n "${FAKE_NPM_VIEW_IGNORE_TERM:-}" ]; then trap '' TERM; fi [ -n "${FAKE_NPM_VIEW_SLEEP:-}" ] && sleep "$FAKE_NPM_VIEW_SLEEP" [ "${FAKE_NPM_VIEW_RC:-0}" = 0 ] || exit "$FAKE_NPM_VIEW_RC" printf '%s\n' "${FAKE_NPM_PUBLISHED:-}" ;; @@ -519,6 +520,26 @@ def test_the_watchdog_ends_a_hung_commands_children_too(box): assert "npm view took longer than 1s" in result.stderr +@pytest.mark.parametrize("fallback", [False, True], ids=["host", "watchdog"]) +def test_a_command_that_ignores_term_is_killed_after_the_grace(box, fallback): + """Copilot on #134: TERM was the only signal, so a command that ignored it + (with its children) held the launch for as long as it liked. KILL follows + five seconds later on every branch: `timeout -k` on a host that has it, + and the watchdog's own on one that does not.""" + box.user_copy("2.1.283") + env = {"FAKE_NPM_VIEW_SLEEP": "30", "FAKE_NPM_VIEW_IGNORE_TERM": "1", + "CLAUDE_CURRENT_TIMEOUT": "1"} + if fallback: + env["PATH"] = path_without(box, "timeout", "gtimeout") + started = time.monotonic() + result = box.run("--porcelain", **env) + elapsed = time.monotonic() - started + assert elapsed < 20, f"KILL never came: {elapsed:.1f}s" + assert result.returncode == 0, result.stderr + assert porcelain(result)["status"] == "unverified" + assert "npm view took longer than 1s" in result.stderr + + def _dead_pid() -> int: proc = subprocess.Popen(["true"]) proc.wait() diff --git a/tests/test_claude_restart_check.py b/tests/test_claude_restart_check.py index 536f474..252cfa1 100644 --- a/tests/test_claude_restart_check.py +++ b/tests/test_claude_restart_check.py @@ -113,6 +113,19 @@ def test_a_binary_npm_replaced_under_the_session_asks_for_a_restart(table): assert result.stdout == restart("2.1.283", "2.1.284") + "\n" +def test_a_minified_package_json_is_read_too(table): + """Copilot on #134: only a `"version"` key on a line of its own was read, + so a minified package.json gave no installed version and a behind session + no notice. Pretty or minified, the first "version" is the one.""" + exe = table.npm_package("2.1.283") + (exe.parent.parent / "package.json").write_text( + '{"name":"@anthropic-ai/claude-code","version":"2.1.284",' + '"bin":{"claude":"bin/claude.exe"},"engines":{"node":">=18"}}') + status_line_tree(table, str(exe)) + result = table.run("--running", "2.1.283", "--pid", "100", NO_COLOR="1") + assert result.stdout == restart("2.1.283", "2.1.284") + "\n" + + def test_the_line_is_green_unless_no_color_is_set(table): table.npm_package("2.1.284") status_line_tree(table, table.replaced_npm_exe()) From 544d8af839e8dfeee4a6441001cfbe07f7cbbb2d Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:47:35 +0000 Subject: [PATCH 11/18] claude-current: one symlink lock for every launch, whatever its PATH holds Copilot's round three on #134 (claude-current:516): the lock was flock where PATH had one and the symlink where it did not, so two launches with different PATHs held two independent locks and both ran the same npm update at once. Every launch now takes the one symlink lock at claude-current.lock.l, and flock is never consulted. The symlink names its owner as pid=@, where is the host's name and, where /proc shows one, the pid namespace's inode. Containers that share a home share this lock, and a pid read in another namespace says nothing about its owner, which flock never needed to know: a lock made at another place is waited on and never taken over, and the refusal names its owner and place. A lock made here whose owner has gone (a zombie included) is taken over under the reaper as before. A cache directory the launch cannot write in, and a file that is not this command's lock at the lock path, are named rather than waited out in silence. Tests: two real launches, one with a flock on PATH (a logging shim, on every host) and one without, exclude each other in both orders, one install, and flock is never called; red on b90f4dc5 in both orders. Lane: openxfactory-5 (openXfactory-5) Co-Authored-By: Claude Opus 5.5 --- claude-current | 145 +++++++++++++++++++++------------- docs/README-claude-current.md | 16 ++-- tests/test_claude_current.py | 134 ++++++++++++++++++++++++++++--- 3 files changed, 222 insertions(+), 73 deletions(-) diff --git a/claude-current b/claude-current index 6c132d8..771ae15 100755 --- a/claude-current +++ b/claude-current @@ -95,8 +95,8 @@ # is not an operator's pin, and resolves again. # # Bash 3.2 safe (macOS): no mapfile, no associative arrays, no readlink -f, -# no GNU-only flags; `timeout`, else `gtimeout`, else a watchdog; `flock`, else -# a symlink lock. +# no GNU-only flags; `timeout`, else `gtimeout`, else a watchdog; ONE symlink +# lock for every launch, never `flock`, so what PATH holds cannot split it. set -euo pipefail @@ -164,7 +164,7 @@ for setting in TIMEOUT VERSION_TIMEOUT UPDATE_TIMEOUT LOCK_WAIT; do done NATIVE_DIR="${CLAUDE_CURRENT_NATIVE_DIR:-$HOME/.local/share/claude/versions}" CACHE_DIR="${CLAUDE_CURRENT_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/openrepotools}" -LOCK_FILE="$CACHE_DIR/claude-current.lock" +LOCK="$CACHE_DIR/claude-current.lock.l" # A version is x.y.z, optionally with a pre-release or build tail. version_re='^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.+-]+)?$' @@ -499,91 +499,126 @@ EOF } # ------------------------------------------------------------ the lock - -lock_fd_open=0 +# +# ONE LOCK FOR EVERY LAUNCH, WHATEVER ITS PATH HOLDS (Copilot on #134). The +# lock was `flock` where PATH had one and a symlink where it did not, so two +# launches with different PATHs held two independent locks and both updated +# the same install at once. Every launch now takes the one symlink at $LOCK, +# and `flock` is never consulted. +# +# THE SYMLINK'S TEXT NAMES ITS OWNER, `pid=@`. `ln -s` makes the +# lock and publishes the owner in one atomic act, so a launch killed at any +# point leaves either no lock or a lock that names it, never an ownerless one +# every later launch would wait on. is where that pid means anything: +# the host's name and, where /proc shows one, the inode of the pid namespace. +# Containers that share one home share this lock, and a pid read in another +# namespace says nothing about whether its owner still runs, which `flock` +# never needed to know. So a lock made ELSEWHERE is waited on and never taken +# over, and the refusal names it and its place; a lock made HERE whose owner +# is no longer running is taken over. +# +# ONE LAUNCH REMOVES A DEAD OWNER'S LOCK, and it holds a second directory, the +# reaper, while it looks again. Two launches that both read the same dead pid +# would otherwise both remove "the stale lock", and the second removal would +# take the lock the first had just made, so both would update at once. Under +# the reaper the lock is removed only while it still names the owner that was +# seen dead. A reaper left by a launch killed in those few lines blocks the +# takeover, and the refusal names it. + +OWNER="" +PLACE="" link_lock="" lock_why="" +# lock_place: this launch's , `` or `:`. +# No `/`, because `ln -s` onto a directory names the link it puts inside after +# the target's last component. +lock_place() { + local host ns + host="$(uname -n 2>/dev/null || :)" + ns="$(readlink "/proc/$$/ns/pid" 2>/dev/null || :)" + # `pid:[4026531836]` on Linux, nothing without /proc: its digits or none. + ns="${ns//[!0-9]/}" + case "$host" in '' | *[/@:]*) host=unknown ;; esac + printf '%s' "$host${ns:+:$ns}" +} + # take_lock: 0 with the lock held, or 1 with lock_why saying why not. take_lock() { - local waited=0 d owner reaped + local waited=0 missing=0 record owner where reaped if ! mkdir -p -- "$CACHE_DIR" 2>/dev/null; then lock_why="the update lock's directory $CACHE_DIR could not be created" return 1 fi - lock_why="the update lock $LOCK_FILE was not free within ${LOCK_WAIT}s" - if command -v flock >/dev/null 2>&1; then - if ! exec 9>>"$LOCK_FILE"; then - lock_why="the update lock $LOCK_FILE could not be opened" - return 1 - fi - lock_fd_open=1 - if flock -w "$LOCK_WAIT" 9; then - lock_why="" - return 0 - fi - exec 9>&- - lock_fd_open=0 - return 1 - fi - # No flock on a stock macOS: the lock is a SYMLINK whose text names its - # owner, `pid=`. `ln -s` makes it and publishes the owner in one - # atomic act, so a launch killed at any point leaves either no lock or a - # lock that names it, never an ownerless one every later launch would wait - # on. A lock whose owner is no longer running is taken over, not waited out. - # - # ONE LAUNCH REMOVES A DEAD OWNER'S LOCK, and it holds a second directory, - # the reaper, while it looks again. Two launches that both read the same - # dead pid would otherwise both remove "the stale lock", and the second - # removal would take the lock the first had just made, so both would - # update at once. Under the reaper the lock is removed only while it still - # names the pid that was seen dead. A reaper left by a launch killed in - # those few lines blocks the takeover, and the refusal names it. - d="$LOCK_FILE.l" + PLACE="$(lock_place)" + OWNER="pid=$$@$PLACE" while :; do - if ln -s "pid=$$" "$d" 2>/dev/null; then - [ "$(readlink -- "$d" 2>/dev/null || :)" = "pid=$$" ] && break + if ln -s "$OWNER" "$LOCK" 2>/dev/null; then + [ "$(readlink -- "$LOCK" 2>/dev/null || :)" = "$OWNER" ] && break # A directory at that path: `ln` put the link inside it. - rm -f -- "$d/pid=$$" 2>/dev/null || : - lock_why="$d is a directory and not this command's lock: remove it" + rm -f -- "$LOCK/$OWNER" 2>/dev/null || : + lock_why="$LOCK is a directory and not this command's lock: remove it" return 1 fi - owner="$(readlink -- "$d" 2>/dev/null || :)" - owner="${owner#pid=}" + if [ ! -L "$LOCK" ] && [ ! -e "$LOCK" ]; then + # Nothing there and still no lock: one that was released between + # the two acts is taken at the next try, and a directory this + # launch cannot write in is named now rather than waited out. + missing=$((missing + 1)) + if [ "$missing" -ge 2 ]; then + lock_why="the update lock $LOCK could not be made in $CACHE_DIR" + return 1 + fi + continue + fi + missing=0 + record="$(readlink -- "$LOCK" 2>/dev/null || :)" + owner="" + where="" + case "$record" in + pid=*@*) + owner="${record#pid=}" + owner="${owner%%@*}" + where="${record#*@}" ;; + esac case "$owner" in '' | *[!0-9]*) owner="" ;; esac - if [ -n "$owner" ] && ! kill -0 "$owner" 2>/dev/null && - mkdir -- "$d.reap" 2>/dev/null; then + # A lock made here whose owner has gone, or that names this very pid + # (a dead launch's, which this one never took), is a lock nobody holds. + if [ -n "$owner" ] && [ "$where" = "$PLACE" ] && + { [ "$owner" = "$$" ] || ! running "$owner"; } && + mkdir -- "$LOCK.reap" 2>/dev/null; then reaped=0 - if [ "$(readlink -- "$d" 2>/dev/null || :)" = "pid=$owner" ] && - rm -f -- "$d" 2>/dev/null && [ ! -L "$d" ]; then + if [ "$(readlink -- "$LOCK" 2>/dev/null || :)" = "$record" ] && + rm -f -- "$LOCK" 2>/dev/null && [ ! -L "$LOCK" ]; then reaped=1 fi - rmdir -- "$d.reap" 2>/dev/null || : + rmdir -- "$LOCK.reap" 2>/dev/null || : # Taken over: try for the lock at once. Not taken over (another # launch got there first, or the lock would not go): wait as usual. [ "$reaped" = 1 ] && continue fi if [ "$waited" -ge "$LOCK_WAIT" ]; then - [ -d "$d.reap" ] && - lock_why="$lock_why, and $d.reap, the directory a launch holds while it removes a dead owner's lock, is still there: remove it if no launch is running" + lock_why="the update lock $LOCK was not free within ${LOCK_WAIT}s" + if [ -n "$owner" ] && [ "$where" != "$PLACE" ]; then + lock_why="$lock_why. It is held by pid $owner at $where, and this launch runs at $PLACE, where that pid says nothing about whether its owner still runs: remove $LOCK if no launch is running there" + elif [ -z "$owner" ]; then + lock_why="$lock_why. It is not a lock this command makes (it reads '${record:-not a symlink}'): remove $LOCK if no launch is running" + fi + [ -d "$LOCK.reap" ] && + lock_why="$lock_why, and $LOCK.reap, the directory a launch holds while it removes a dead owner's lock, is still there: remove it if no launch is running" return 1 fi sleep 1 waited=$((waited + 1)) done - link_lock="$d" + link_lock="$LOCK" lock_why="" return 0 } release_lock() { - if [ "$lock_fd_open" = 1 ]; then - flock -u 9 2>/dev/null || : - exec 9>&- - lock_fd_open=0 - fi if [ -n "$link_lock" ]; then - if [ "$(readlink -- "$link_lock" 2>/dev/null || :)" = "pid=$$" ]; then + if [ "$(readlink -- "$link_lock" 2>/dev/null || :)" = "$OWNER" ]; then rm -f -- "$link_lock" fi link_lock="" diff --git a/docs/README-claude-current.md b/docs/README-claude-current.md index 3bf548a..103c3dc 100644 --- a/docs/README-claude-current.md +++ b/docs/README-claude-current.md @@ -71,11 +71,17 @@ one by one, numbers as numbers (`beta.9` before `beta.10`) and words in byte order. Build metadata after a `+` is ignored. **The update runs under a lock** at -`${XDG_CACHE_HOME:-~/.cache}/openrepotools/claude-current.lock`: `flock` where -it exists, else a symlink whose text names its owner (`pid=`). One -`ln -s` makes that lock and names its owner, so no lock is ever left without -one. A lock whose owner has died is taken over, by one launch only: the one -that holds a second directory, the reaper, while it reads the owner again. +`${XDG_CACHE_HOME:-~/.cache}/openrepotools/claude-current.lock.l`, the same one +for every launch whatever its `PATH` holds (`flock` is never used, so two +launches can never hold two different locks): a symlink whose text names its +owner, `pid=@`. One `ln -s` makes that lock and names its owner, +so no lock is ever left without one. `` is the host's name and, on +Linux, the pid namespace's inode, because containers that share a home share +the lock and a pid from another namespace says nothing about its owner. A lock +made at another place is waited on and never taken over, and the refusal names +its owner and place. A lock made here whose owner has died is taken over, by +one launch only: the one that holds a second directory, the reaper, while it +reads the owner again. After it takes the lock it reads the candidates again, because another launch may have updated while it waited. A lock it cannot take within `CLAUDE_CURRENT_LOCK_WAIT` seconds is a refusal, unless that other launch's diff --git a/tests/test_claude_current.py b/tests/test_claude_current.py index 5e64c2b..d5bd056 100644 --- a/tests/test_claude_current.py +++ b/tests/test_claude_current.py @@ -338,14 +338,24 @@ def test_nothing_installed_is_installed_into_the_user_prefix(box): assert (fields["path"], fields["status"]) == (str(box.prefix / "bin" / "claude"), "verified") +def _place() -> str: + """The `` a launch on this host writes into its lock: the host's + name and, where /proc shows one, the pid namespace's inode.""" + host = os.uname().nodename + if not host or any(c in host for c in "/@:"): + host = "unknown" + try: + ns = "".join(c for c in os.readlink("/proc/self/ns/pid") if c.isdigit()) + except OSError: + ns = "" + return f"{host}:{ns}" if ns else host + + def _hold_lock(box: Sandbox, seconds: float, then: str = ":") -> subprocess.Popen: """Hold the update lock the way a second launch would, then run `then`.""" - lock = box.cache / "claude-current.lock" - if shutil.which("flock"): - script = f'exec 9>>"{lock}"; flock 9; touch "{box.root}/held"; sleep {seconds}; {then}' - else: - script = (f'ln -s "pid=$$" "{lock}.l"; touch "{box.root}/held"; ' - f'sleep {seconds}; {then}; rm -f "{lock}.l"') + lock = box.cache / "claude-current.lock.l" + script = (f'ln -s "pid=$$@{_place()}" "{lock}"; touch "{box.root}/held"; ' + f'sleep {seconds}; {then}; rm -f "{lock}"') proc = subprocess.Popen(["bash", "-c", script], env=box.env()) deadline = time.monotonic() + 10 while not (box.root / "held").exists(): @@ -546,11 +556,11 @@ def _dead_pid() -> int: return proc.pid -def _link_lock(box: Sandbox, pid: int) -> Path: - """The lock as a launch without `flock` makes it: a symlink whose text - names its owner.""" +def _link_lock(box: Sandbox, pid: int, place: str | None = None) -> Path: + """The lock as every launch makes it: a symlink whose text names its + owner, `pid=@`, at this host's place unless told another.""" lock = box.cache / "claude-current.lock.l" - lock.symlink_to(f"pid={pid}") + lock.symlink_to(f"pid={pid}@{_place() if place is None else place}") return lock @@ -580,8 +590,10 @@ def test_the_link_lock_names_its_owner_while_it_is_held(box): result = box.run("--porcelain", PATH=path_without(box, "flock"), FAKE_NPM_INSTALL_HOOK=f'readlink "{lock}" > "{seen}"') assert result.returncode == 0, result.stderr - assert seen.read_text().startswith("pid="), seen.read_text() - assert int(seen.read_text().strip()[4:]) > 0 + owner = re.fullmatch(r"pid=([0-9]+)@(.+)", seen.read_text().strip()) + assert owner, seen.read_text() + assert int(owner.group(1)) > 0 + assert owner.group(2) == _place(), "the lock does not name where its pid runs" assert not lock.is_symlink(), "the lock was not released" @@ -620,12 +632,108 @@ def test_a_live_link_lock_is_waited_on_and_never_removed(box): lock = _link_lock(box, os.getpid()) result = box.run(CLAUDE_CURRENT_LOCK_WAIT="1", PATH=path_without(box, "flock")) assert result.returncode == 2, result.stderr - assert os.readlink(lock) == f"pid={os.getpid()}" + assert os.readlink(lock) == f"pid={os.getpid()}@{_place()}" assert "was not free within 1s" in result.stderr assert "the directory a launch holds" not in result.stderr assert box.installs() == [] +def _flock_on_path(box: Sandbox) -> tuple[str, Path]: + """A PATH that HAS a `flock`, on any host: a shim first on it that logs + every call and hands it to the host's own `flock` where there is one.""" + shim_dir = box.root / "flock-shim" + log = box.root / "flock.log" + log.write_text("") + real = shutil.which("flock") or "" + box._script(shim_dir / "flock", + f'#!/usr/bin/env bash\nprintf \'%s\\n\' "$*" >> "{log}"\n' + + (f'exec "{real}" "$@"\n' if real else "exit 0\n")) + return f"{box.bin}{os.pathsep}{shim_dir}{os.pathsep}{os.environ.get('PATH', '/usr/bin:/bin')}", log + + +@pytest.mark.parametrize("holder_has_flock", [True, False], + ids=["flock-holds-no-flock-waits", "no-flock-holds-flock-waits"]) +def test_launches_with_and_without_flock_on_path_exclude_each_other(box, holder_has_flock): + """Copilot on #134: the lock was `flock` when PATH had one and a symlink + when it did not, so a launch of each kind held its own lock and both + updated at once. Two real launches here, one with a `flock` on PATH and + one without, in both orders: the second waits for the first's update and + installs nothing, and `flock` is never called at all.""" + box.user_copy("2.1.283") + with_flock, flock_log = _flock_on_path(box) + without_flock = path_without(box, "flock") + held = box.root / "held" + first, second = ((with_flock, without_flock) if holder_has_flock + else (without_flock, with_flock)) + holder = subprocess.Popen( + [str(CMD), "--porcelain"], cwd=str(box.root), text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env=box.env(PATH=first, FAKE_NPM_INSTALL_HOOK=f'touch "{held}"; sleep 3')) + try: + deadline = time.monotonic() + 30 + while not held.exists(): + assert holder.poll() is None, holder.communicate() + assert time.monotonic() < deadline, "the first launch never began its update" + time.sleep(0.05) + result = box.run("--porcelain", PATH=second, CLAUDE_CURRENT_LOCK_WAIT="30") + finally: + out, err = holder.communicate(timeout=60) + assert holder.returncode == 0, err + assert result.returncode == 0, result.stderr + assert porcelain(result)["status"] == "verified" + assert "updated by another launch" in result.stderr + assert len(box.installs()) == 1, "both launches updated: two locks, not one" + assert flock_log.read_text() == "", "flock was consulted, so PATH still picks the lock" + + +def test_a_dead_owners_lock_made_at_another_place_is_waited_on_and_named(box): + """A container that shares this home shares this lock, and a pid read in + another pid namespace says nothing about its owner. So a lock whose place + is not this launch's is never taken over, dead as its pid looks here, and + the refusal says whose it is and where.""" + box.user_copy("2.1.283") + dead = _dead_pid() + lock = _link_lock(box, dead, place="another-host:4026531999") + result = box.run(CLAUDE_CURRENT_LOCK_WAIT="1") + assert result.returncode == 2, result.stderr + assert lock.is_symlink(), "a lock made at another place was taken over" + assert f"held by pid {dead} at another-host:4026531999" in result.stderr + assert f"this launch runs at {_place()}" in result.stderr + assert box.installs() == [] + + +def test_a_file_at_the_lock_path_is_named_not_waited_on_silently(box): + """Nothing this command makes: the refusal says what it reads there.""" + box.user_copy("2.1.283") + stray = box.cache / "claude-current.lock.l" + stray.write_text("", encoding="utf-8") + result = box.run(CLAUDE_CURRENT_LOCK_WAIT="1") + assert result.returncode == 2, result.stderr + assert "It is not a lock this command makes (it reads 'not a symlink')" in result.stderr + assert stray.is_file() + assert box.installs() == [] + + +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, + reason="root writes in a read-only directory") +def test_a_lock_directory_it_cannot_write_in_is_named_at_once(box): + """`flock` said "could not be opened" at once; the symlink lock must not + wait out its whole bound for a lock nobody holds.""" + box.user_copy("2.1.283") + box.cache.chmod(0o555) + try: + started = time.monotonic() + result = box.run(CLAUDE_CURRENT_LOCK_WAIT="30") + elapsed = time.monotonic() - started + finally: + box.cache.chmod(0o755) + assert result.returncode == 2, result.stderr + assert f"the update lock {box.cache / 'claude-current.lock.l'} could not be made in {box.cache}" in result.stderr + assert "was not free" not in result.stderr + assert elapsed < 15, f"it waited for a lock nobody holds: {elapsed:.1f}s" + assert box.installs() == [] + + def test_offline_reads_no_npm_and_installs_nothing(box): link = box.user_copy("2.1.283") result = box.run("--porcelain", "--offline") From 715b7ace2f6eeddcf75014e705535fd5c1965ff7 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:47:35 +0000 Subject: [PATCH 12/18] AGENTS.md: the installer places fifteen files Copilot's round three on #134 (openRepoTools:286): INSTALLABLES now holds fifteen names with claude-current and claude-restart-check, and AGENTS.md still said thirteen. One word, line-neutral, so the file stays at the 265-line cap; #93's AGENTS.md hunk is at line 254 and does not touch this paragraph. Lane: openxfactory-5 (openXfactory-5) Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 68cca5c..9361ebb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ Three estate commands, `park`, `resume` and `status`; the lane tooling `lanes-edit.sh`, `lane-start`, `lane-end` and `link-estates`, which came here with their history under lane-collision-protocol Amendment 9; and the -`openRepoTools` that places all thirteen files and creates the workspace they read. +`openRepoTools` that places all fifteen files and creates the workspace they read. **The verbs add no mechanics.** They find the estate and run its own `make park` / `make resume`, which run the Speckit git extension's scripts — one implementation, ruled 2026-09-09 From 66d786296db096aad529077fe1923e9c64136c43 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:44:27 +0000 Subject: [PATCH 13/18] fix(claude): share relative cache locks across checkouts --- claude-current | 7 ++++++ docs/README-claude-current.md | 2 +- tests/test_claude_current.py | 43 +++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/claude-current b/claude-current index 771ae15..84bc840 100755 --- a/claude-current +++ b/claude-current @@ -80,6 +80,7 @@ # (default 330) # CLAUDE_CURRENT_CACHE_DIR where the lock lives (default # ${XDG_CACHE_HOME:-~/.cache}/openrepotools) +# Relative paths are taken under $HOME. # CLAUDE_CURRENT_NPM_PREFIX the user npm prefix, see step 2 # CLAUDE_CURRENT_NATIVE_DIR the native versions directory, see step 2 # CLAUDE_CURRENT_SYSTEM_CANDIDATES colon-separated image paths, see step 2. @@ -164,6 +165,12 @@ for setting in TIMEOUT VERSION_TIMEOUT UPDATE_TIMEOUT LOCK_WAIT; do done NATIVE_DIR="${CLAUDE_CURRENT_NATIVE_DIR:-$HOME/.local/share/claude/versions}" CACHE_DIR="${CLAUDE_CURRENT_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/openrepotools}" +# Every checkout updating this user's install must take the same lock, even +# when the override (or XDG_CACHE_HOME) is relative and their cwd differs. +case "$CACHE_DIR" in + /*) : ;; + *) CACHE_DIR="$HOME/$CACHE_DIR" ;; +esac LOCK="$CACHE_DIR/claude-current.lock.l" # A version is x.y.z, optionally with a pre-release or build tail. diff --git a/docs/README-claude-current.md b/docs/README-claude-current.md index 103c3dc..4c8bf8c 100644 --- a/docs/README-claude-current.md +++ b/docs/README-claude-current.md @@ -121,7 +121,7 @@ apart from 1 because a launcher reports 1 as "no Claude Code". | `CLAUDE_CURRENT_VERSION_TIMEOUT` | `10` | seconds for each `--version` | | `CLAUDE_CURRENT_UPDATE_TIMEOUT` | `300` | seconds for each update command | | `CLAUDE_CURRENT_LOCK_WAIT` | `330` | seconds to wait for another launch's update | -| `CLAUDE_CURRENT_CACHE_DIR` | `${XDG_CACHE_HOME:-~/.cache}/openrepotools` | where the lock lives | +| `CLAUDE_CURRENT_CACHE_DIR` | `${XDG_CACHE_HOME:-~/.cache}/openrepotools` | where the lock lives; relative overrides or `XDG_CACHE_HOME` paths are taken under `$HOME` | | `CLAUDE_CURRENT_NPM_PREFIX` | see candidate 2 | the user npm prefix, the one place an update writes | | `CLAUDE_CURRENT_NATIVE_DIR` | `~/.local/share/claude/versions` | the native versions directory | | `CLAUDE_CURRENT_SYSTEM_CANDIDATES` | `/usr/local/bin/claude:/usr/bin/claude` | the image's copies | diff --git a/tests/test_claude_current.py b/tests/test_claude_current.py index d5bd056..e116b92 100644 --- a/tests/test_claude_current.py +++ b/tests/test_claude_current.py @@ -760,6 +760,49 @@ def test_no_npm_at_all_is_unverified_not_a_crash(box): # --- the user prefix ---------------------------------------------------------- +@pytest.mark.parametrize("setting", ["CLAUDE_CURRENT_CACHE_DIR", "XDG_CACHE_HOME"]) +def test_relative_cache_shares_the_update_lock_across_checkouts(box, setting): + """Two checkouts sharing one install must not get independent cwd locks.""" + box.user_copy("2.1.283") + first_cwd = box.root / "repo-a" + second_cwd = box.root / "repo-b" + first_cwd.mkdir() + second_cwd.mkdir() + entered = box.root / "updating" + release = box.root / "release" + overrides = {"CLAUDE_CURRENT_CACHE_DIR": "", setting: "relative-cache"} + cache = box.home / "relative-cache" + if setting == "XDG_CACHE_HOME": + cache /= "openrepotools" + holder_env = box.env(**overrides, FAKE_NPM_INSTALL_HOOK=( + 'touch "$HOME/../updating"; ' + 'while [ ! -e "$HOME/../release" ]; do sleep 0.05; done')) + holder = subprocess.Popen([str(CMD), "--porcelain"], cwd=first_cwd, + env=holder_env, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + try: + deadline = time.monotonic() + 15 + while not entered.exists(): + assert holder.poll() is None, "first launch exited before updating" + assert time.monotonic() < deadline, "first launch never began updating" + time.sleep(0.02) + result = subprocess.run([str(CMD), "--porcelain"], cwd=second_cwd, + env=box.env(**overrides, CLAUDE_CURRENT_LOCK_WAIT="1"), + capture_output=True, text=True, timeout=30) + assert result.returncode == 2, result.stderr + assert "was not free within 1s" in result.stderr + assert (cache / "claude-current.lock.l").is_symlink() + assert len(box.installs()) == 1, "second checkout started a competing install" + assert not (first_cwd / "relative-cache").exists() + assert not (second_cwd / "relative-cache").exists() + finally: + release.touch() + stdout, stderr = holder.communicate(timeout=30) + assert holder.returncode == 0, stderr + assert porcelain(subprocess.CompletedProcess([], 0, stdout, stderr))["status"] == "verified" + assert len(box.installs()) == 1 + + def test_the_user_prefix_falls_back_to_npm_config_prefix_then_to_npm_global(box): elsewhere = box.root / "configured-prefix" env = {"CLAUDE_CURRENT_NPM_PREFIX": ""} From 208e50672ba05390892e87dd21e334aaeffaa49b Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:03:47 +0000 Subject: [PATCH 14/18] fix(claude): preflight lane launches and qualify newer-copy notices --- claude-restart-check | 27 ++- docs/README-claude-current.md | 27 ++- lane-start | 282 ++++++++++++------------ tests/test_claude_restart_check.py | 37 +++- tests/test_lane_start_claude_current.py | 28 +++ 5 files changed, 235 insertions(+), 166 deletions(-) diff --git a/claude-restart-check b/claude-restart-check index 68d2ee5..ea3139d 100755 --- a/claude-restart-check +++ b/claude-restart-check @@ -1,7 +1,6 @@ #!/usr/bin/env bash -# claude-restart-check: one green line when the Claude Code session this runs -# under was started from a binary that has since been replaced on disk, or -# runs an older version than the one installed. Nothing otherwise. +# claude-restart-check: one green restart line for a replaced binary, or a +# newer-copy notice when another installed version is newer. Nothing otherwise. # # Installed on PATH by `openRepoTools --install`, for workBenches' shared # status line to call. @@ -39,7 +38,8 @@ # native versions directory ($CLAUDE_CURRENT_NATIVE_DIR, default # ~/.local/share/claude/versions), and the package in the user npm prefix, # found the way claude-current finds it. So a session on the npm copy is -# told about a newer native install, which the next launch starts. +# told about a newer native install. The next launch checks npm and may +# prefer a candidate equal to npm over that newer installed copy. # - It warns when the binary was replaced, or when the installed version is # newer than the running one (--running, else the native file's name). # It reads /proc, a directory listing and a few small files. It reaches no @@ -49,6 +49,8 @@ # OUTPUT # Nothing, or exactly one line: # RESTART NEEDED: running , installed ; /ctx at your next breakpoint +# or, when only the version differs and the binary was not replaced: +# NEWER COPY INSTALLED: running , installed ; /ctx checks npm before selecting a version # in green, or plain when NO_COLOR is set. # # EXIT @@ -78,10 +80,10 @@ usage() { claude-restart-check [--running ] [--pid ] claude-restart-check --help -Print one green "RESTART NEEDED: running , installed ; /ctx at your -next breakpoint" line when the Claude Code session above this process runs a -binary since replaced on disk, or one older than the version installed; print -nothing otherwise. Always exits 0 (64 for an unknown argument). Reads /proc +Print a green RESTART NEEDED line for a binary replaced on disk, or a +NEWER COPY INSTALLED notice when only the installed version is newer. That +notice does not promise /ctx will select the newer copy: the next launch +checks npm first. Print nothing otherwise. Always exits 0 (64 for an unknown argument). Reads /proc (CLAUDE_RESTART_CHECK_PROC) and never acts on the session. USAGE } @@ -293,7 +295,8 @@ replaced=0 # install claude-current reads that has its version on disk: the running # binary's own, the native versions directory and the user npm prefix's # package. A session on the npm copy is behind a newer native install, which -# the next launch starts (Copilot on opensoft/openRepoTools#134). +# may be considered at the next launch. This read has no registry information, +# so it cannot predict the resolver's preference for a copy equal to npm. installed="" from_path="" consider() { # : kept when it is higher than the one in hand @@ -327,7 +330,11 @@ if [ -n "$installed" ] && [ -n "$running" ] && [ "$(vercmp "$installed" "$runnin fi [ "$replaced" = 1 ] || [ "$newer" = 1 ] || exit 0 -line="RESTART NEEDED: running ${running:-an older build}, installed ${installed:-a newer build}; /ctx at your next breakpoint" +if [ "$replaced" = 1 ]; then + line="RESTART NEEDED: running ${running:-an older build}, installed ${installed:-a newer build}; /ctx at your next breakpoint" +else + line="NEWER COPY INSTALLED: running $running, installed $installed; /ctx checks npm before selecting a version" +fi if [ -n "${NO_COLOR:-}" ]; then printf '%s\n' "$line" else diff --git a/docs/README-claude-current.md b/docs/README-claude-current.md index 4c8bf8c..3bf0e3c 100644 --- a/docs/README-claude-current.md +++ b/docs/README-claude-current.md @@ -155,9 +155,9 @@ an inherited answer from an operator's pin: ## `lane-start` -`lane-start` applies that table at the end of step 5a, once it knows the agent -and the launch, and before step 5 writes anything (and in one earlier launch, -below): +`lane-start` applies that table after confirmation and agent selection, before +requesting a binding handoff, renaming a window or moving a transcript (and in +the bare launch described below): - It looks for `claude-current` beside itself, then in `$OPENREPOTOOLS_BIN_DIR` (default `~/.local/bin`), and never on `PATH`. A @@ -169,9 +169,8 @@ below): `CLAUDE_BIN` and `CLAUDE_RESOLVED_BIN` are exported with that path, and one line says `launching claude () at `. - Exit 2 ends `lane-start` with 2, and any other failure with 1. Either way - the row, the object log and the handoff's Rule 3 stamp are not written. The - window has already been renamed for the lane by step 4, as it has for the - launcher table's own check on the agent's command. + no binding handoff is requested, the window is not renamed, no transcript is + moved, and the row, object log and handoff's Rule 3 stamp are not written. - With no `claude-current` installed, one note says so, and `CLAUDE_BIN` (`claude` from `PATH` when unset) is launched unchecked, as before. - `--dry-run` prints a `PLAN` line naming the call and runs nothing, because @@ -223,16 +222,28 @@ It warns when either of these holds: versions directory (`$CLAUDE_CURRENT_NATIVE_DIR`, default `~/.local/share/claude/versions`), and the package in the user npm prefix, found the way `claude-current` finds it. So a session on the npm copy is - told about a newer native install, which the next launch starts. The + told about a newer native install. The running version is `--running` (the status line JSON's `version`), else the native file's own name. -The warning is exactly one line, green unless `NO_COLOR` is set: +The warning is exactly one line, green unless `NO_COLOR` is set. A replaced +binary asks for a restart: ```text RESTART NEEDED: running 2.1.283, installed 2.1.284; /ctx at your next breakpoint ``` +When only another installed version is newer, the notice leaves the selection +to the next launch's registry check: + +```text +NEWER COPY INSTALLED: running 2.1.284, installed 2.1.285; /ctx checks npm before selecting a version +``` + +`claude-current` prefers a candidate equal to npm's published version over an +ahead candidate. This disk-only check cannot predict that choice, so a newer +installed copy alone is not labelled `RESTART NEEDED`. + It never acts. There is no kill and no automatic `/ctx`, because a working session is never interrupted. It reads `/proc`, one directory listing and a few small files, reaches no network and runs no binary, so the render is not diff --git a/lane-start b/lane-start index bfe2521..ffdbbe9 100755 --- a/lane-start +++ b/lane-start @@ -601,7 +601,7 @@ PROJECTS_STATE="${CLAUDE_PROJECTS_DIR:-$CLAUDE_HOME/projects}" # 2026-09-29, verbatim "for 2, we can run update on every start, this ensures # we have the latest models"). Decided here, from the environment as it # arrived, and acted on by `lane_claude_resolve_launch` below, once a launch -# is certain: at the foot of 5a, and in the bare launch a `--confirm` +# is certain: before the binding handoff, and in the bare launch a `--confirm` # answered No takes. Three shapes, and docs/README-claude-current.md has the # contract: # * CLAUDE_BIN unset: resolve through `claude-current`, the one `--install` @@ -648,8 +648,8 @@ lane_claude_current() { # CLAUDE_RESOLVED_BIN. Its exit 2 is a refusal (every copy still behind npm) # and ends this run with 2; any other failure (1, nothing runnable; 64, a # malformed setting) ends it with 1, the environment status. Both callers come -# before any write: the foot of 5a, where the launcher table's own PATH check -# sits for the same reason, and the bare launch a `--confirm` answered No +# before a binding handoff, window rename or transcript move, and the bare +# launch a `--confirm` answered No # takes. Its stderr is the operator's and goes straight through. Never under # `--no-launch`, which launches nothing and is the first act INSIDE a running # session, and never under `--dry-run`, which must not update anything. It @@ -1996,6 +1996,144 @@ elif [ -n "$win_sid" ]; then step " it is the session step 3 already found holding $LANE in this window — the cell is current" fi +# ------------------------------------------------------------- 3c. --confirm +# +# A window whose name is not the lane is a window whose lane came from somewhere +# else — the swap RECORD, or a flag — and taking it renames the window and +# writes the register. `--confirm` puts the three facts that disagree in front +# of a person first: the WINDOW, the SESSION live in it, and the ROW. +# +# NO TERMINAL AND NO `--yes` IS A REFUSAL, exit 2, before the rename and before +# any write. A question nobody can answer is not a Yes, and a launcher that read +# it as one would rename a window somebody else's lane is working in. +if (( confirm )); then + if [ "$this_window_name" = "$LANE" ]; then + step "--confirm: this window is already named $LANE — nothing to confirm" + else + # AMENDMENT 15 — A WINDOW NAMED FOR THIS LANE IN ANOTHER CASE IS NOT "not + # the lane". The rename still happens below, because the row's spelling is + # what the window is named; what changes is the sentence the operator is + # asked to answer. Told their own lane's window is not the lane, the + # reasonable answer is N — and N abandons a resume that was legitimate. + if [ "$(printf '%s' "$this_window_name" | tr 'A-Z' 'a-z')" = "$(printf '%s' "$LANE" | tr 'A-Z' 'a-z')" ]; then + why="named '$this_window_name', which is THIS LANE under another case — a lane name is ONE name under any case (Amendment 15), and the rename below is to the register row's own spelling, $LANE" + else + case "$this_window_name" in + ""|claude|bash|zsh|sh|node) why="auto-named '${this_window_name:-none}'" ;; + *) why="named '$this_window_name', which is not the lane" ;; + esac + fi + note "--confirm: this window is $why. The three facts, before anything is renamed or written:" + note " window $this_window '${this_window_name:-none}'" + if [ -n "$win_sid" ]; then + note " session $win_sid '${win_name:-none}' (pid $win_pid, profile ${win_profile:-unknown})" + else + note " session none — no live session record names this window" + fi + if [ "$row_count" = 1 ]; then + note " row ${row:0:200}" + elif [ -n "$pub_row" ]; then + pub_row_first="${pub_row%%$'\n'*}" + note " row ${pub_row_first:0:200}" + else + note " row none — the register has no row for $LANE" + fi + answer=""; no_tty=0 + if (( take_yes )); then + step "Take lane $LANE in this window? [y/N] y (--yes)" + answer=y + elif [ -n "${LANE_START_ANSWER+set}" ]; then + step "Take lane $LANE in this window? [y/N] ${LANE_START_ANSWER:-} (LANE_START_ANSWER)" + answer="$LANE_START_ANSWER" + elif [ -t 0 ]; then + printf '%s: Take lane %s in this window? [y/N] ' "$prog" "$LANE" >&2 + IFS= read -r answer || answer="" + else + answer="" + no_tty=1 + fi + case "$answer" in + y|Y|yes|YES|Yes) step "taking lane $LANE in this window" ;; + *) + # AMENDMENT 8(c)/(d), R-A8-3 — EVERY ANSWER LEADS SOMEWHERE AND NO + # ANSWER EXITS. `claude-profile` EXECS this script, so this script's + # exit status IS the launcher's: a refusal here is an exited pane with + # no Claude in it, which is the one failure the whole change exists to + # prevent. `N` means NOT THIS LANE, not NOT AT ALL — and no tty with no + # `--yes` is the same answer with nobody there to give it, which must + # still not take a lane on an inference nobody confirmed. Both fall + # through to Claude, bare: nothing renamed, nothing written, no lane, + # ONE notice, exit 0. Exit 2 is left to argument errors alone. + if (( no_tty )); then + bare_why="--confirm was passed, there is no terminal to ask on, and --yes was not, so lane $LANE is NOT taken in a window $why" + else + bare_why="lane $LANE is NOT taken: the answer was '${answer:-}'" + fi + note "$bare_why. Nothing has been renamed and nothing has been written. Starting Claude with no lane — to take it later, run: $prog $self_args" + lane_claude_resolve_launch + bare=("$CLAUDE_BIN" ${pass[@]+"${pass[@]}"}) + if (( no_launch )); then + # The launcher asked for a command to exec; it gets the bare one, on + # stdout, exactly as it would have got the lane's. + quoted "${bare[@]}"; printf '\n' + step "--no-launch: the command above was printed, not run" + exit 0 + fi + if (( dry_run )); then + plan "exec $(quoted "${bare[@]}")" + step "--dry-run: nothing was renamed, written or launched" + exit 0 + fi + step "exec: $(quoted "${bare[@]}")" + exec "${bare[@]}" + ;; + esac + fi +fi + +# Choose the agent and resolve Claude before a handoff, rename or transcript move. +# The last PAUSED agent is read once for this invocation; an explicit flag wins. +AGENT=""; AGENT_EXPLICIT=0; AGENT_ID=""; RECORDED_AGENT=""; RECORDED_AGENT_READ=0 +read_recorded_agent() { + rra_out=""; rra_rc=0 + rra_out="$("$LANES_EDIT" lane-agent "$LANE" 2>/dev/null)" || rra_rc=$? + RECORDED_AGENT_READ=1 + case "$rra_rc" in + 0) RECORDED_AGENT="$rra_out" ;; + 8|2) RECORDED_AGENT="" ;; + # A READ THAT FAILED IS NOT "THIS LANE'S RECORD NAMES NO AGENT", and the + # helper says so in its own refusal: *"a caller that read it that way would + # launch the default agent over a lane another one paused"* (Amendment + # 7(d), `lanes-edit.sh`'s `lane-agent` arm). This used to `note` and launch + # `claude` anyway — the one act in this whole command that no later refusal + # can undo — so it refuses, and names the flag that goes past it, which is + # a person naming the agent rather than this command guessing at one. + *) die "\`$LANES_EDIT lane-agent $LANE\` failed (exit $rra_rc), so which agent paused this lane is UNKNOWN — and launching the default over a lane another agent paused is what Amendment 7(d) has this read refuse rather than answer. Run it by hand to see what it says, or name the agent: $prog --agent …" 2 ;; + esac + return 0 +} +if [ -n "$agent_flag" ]; then + AGENT="$agent_flag"; AGENT_EXPLICIT=1 +else + read_recorded_agent + if [ -n "$RECORDED_AGENT" ]; then AGENT="$RECORDED_AGENT"; fi + [ -n "$AGENT" ] || AGENT=claude + [ "$AGENT" = claude ] || AGENT_EXPLICIT=1 +fi +case "$AGENT" in + *[!A-Za-z0-9._-]*|'') die "--agent takes a manifest key — letters, digits, '.', '_', '-' — and '$AGENT' is not one (Amendment 17(b))" 2 ;; +esac +# THE AGENT THIS LANE IS RUNNING, IN THE ENVIRONMENT OF THE SESSION IT STARTS +# (Copilot round 2 on openRepoTools#47). `lane-handoff` derives the record's +# `agent` sub-field from `$LANES_AGENT`, else `claude` — so a `codex` session +# launched here would write `agent claude` at its own handoff and the next +# `lane-start` would resume it with the wrong launcher, one record along. The +# launch decides which agent this is; the launch is therefore what says so. +export LANES_AGENT="$AGENT" +if [ "$AGENT" = claude ]; then + lane_claude_resolve_launch +fi + # ------------- 3b(ii). AMENDMENT 18(b): ONE BINDING PER LANE, AND THIS PLACE # # *"A bound lane is started nowhere else — not a second window on the same host, @@ -2104,101 +2242,6 @@ if [ "$bind_rc" = 0 ]; then fi fi -# ------------------------------------------------------------- 3c. --confirm -# -# A window whose name is not the lane is a window whose lane came from somewhere -# else — the swap RECORD, or a flag — and taking it renames the window and -# writes the register. `--confirm` puts the three facts that disagree in front -# of a person first: the WINDOW, the SESSION live in it, and the ROW. -# -# NO TERMINAL AND NO `--yes` IS A REFUSAL, exit 2, before the rename and before -# any write. A question nobody can answer is not a Yes, and a launcher that read -# it as one would rename a window somebody else's lane is working in. -if (( confirm )); then - if [ "$this_window_name" = "$LANE" ]; then - step "--confirm: this window is already named $LANE — nothing to confirm" - else - # AMENDMENT 15 — A WINDOW NAMED FOR THIS LANE IN ANOTHER CASE IS NOT "not - # the lane". The rename still happens below, because the row's spelling is - # what the window is named; what changes is the sentence the operator is - # asked to answer. Told their own lane's window is not the lane, the - # reasonable answer is N — and N abandons a resume that was legitimate. - if [ "$(printf '%s' "$this_window_name" | tr 'A-Z' 'a-z')" = "$(printf '%s' "$LANE" | tr 'A-Z' 'a-z')" ]; then - why="named '$this_window_name', which is THIS LANE under another case — a lane name is ONE name under any case (Amendment 15), and the rename below is to the register row's own spelling, $LANE" - else - case "$this_window_name" in - ""|claude|bash|zsh|sh|node) why="auto-named '${this_window_name:-none}'" ;; - *) why="named '$this_window_name', which is not the lane" ;; - esac - fi - note "--confirm: this window is $why. The three facts, before anything is renamed or written:" - note " window $this_window '${this_window_name:-none}'" - if [ -n "$win_sid" ]; then - note " session $win_sid '${win_name:-none}' (pid $win_pid, profile ${win_profile:-unknown})" - else - note " session none — no live session record names this window" - fi - if [ "$row_count" = 1 ]; then - note " row ${row:0:200}" - elif [ -n "$pub_row" ]; then - pub_row_first="${pub_row%%$'\n'*}" - note " row ${pub_row_first:0:200}" - else - note " row none — the register has no row for $LANE" - fi - answer=""; no_tty=0 - if (( take_yes )); then - step "Take lane $LANE in this window? [y/N] y (--yes)" - answer=y - elif [ -n "${LANE_START_ANSWER+set}" ]; then - step "Take lane $LANE in this window? [y/N] ${LANE_START_ANSWER:-} (LANE_START_ANSWER)" - answer="$LANE_START_ANSWER" - elif [ -t 0 ]; then - printf '%s: Take lane %s in this window? [y/N] ' "$prog" "$LANE" >&2 - IFS= read -r answer || answer="" - else - answer="" - no_tty=1 - fi - case "$answer" in - y|Y|yes|YES|Yes) step "taking lane $LANE in this window" ;; - *) - # AMENDMENT 8(c)/(d), R-A8-3 — EVERY ANSWER LEADS SOMEWHERE AND NO - # ANSWER EXITS. `claude-profile` EXECS this script, so this script's - # exit status IS the launcher's: a refusal here is an exited pane with - # no Claude in it, which is the one failure the whole change exists to - # prevent. `N` means NOT THIS LANE, not NOT AT ALL — and no tty with no - # `--yes` is the same answer with nobody there to give it, which must - # still not take a lane on an inference nobody confirmed. Both fall - # through to Claude, bare: nothing renamed, nothing written, no lane, - # ONE notice, exit 0. Exit 2 is left to argument errors alone. - if (( no_tty )); then - bare_why="--confirm was passed, there is no terminal to ask on, and --yes was not, so lane $LANE is NOT taken in a window $why" - else - bare_why="lane $LANE is NOT taken: the answer was '${answer:-}'" - fi - note "$bare_why. Nothing has been renamed and nothing has been written. Starting Claude with no lane — to take it later, run: $prog $self_args" - lane_claude_resolve_launch - bare=("$CLAUDE_BIN" ${pass[@]+"${pass[@]}"}) - if (( no_launch )); then - # The launcher asked for a command to exec; it gets the bare one, on - # stdout, exactly as it would have got the lane's. - quoted "${bare[@]}"; printf '\n' - step "--no-launch: the command above was printed, not run" - exit 0 - fi - if (( dry_run )); then - plan "exec $(quoted "${bare[@]}")" - step "--dry-run: nothing was renamed, written or launched" - exit 0 - fi - step "exec: $(quoted "${bare[@]}")" - exec "${bare[@]}" - ;; - esac - fi -fi - # ---------------- 3d. AND THE BINDING IS READ AGAIN BEFORE THE FIRST ACT # # A HANDOFF THAT LANDED IS AN OBSERVATION AND NOT A RESERVATION (Copilot round 1 @@ -2619,43 +2662,6 @@ lane_sid="" # (`lane-agent`, this amendment's sibling of `lane-dir`). 8 is "no record of # this amendment yet" — every lane until its own next handoff — and it is not a # failure: `claude` is what this command has always launched. -AGENT=""; AGENT_EXPLICIT=0; AGENT_ID=""; RECORDED_AGENT=""; RECORDED_AGENT_READ=0 -read_recorded_agent() { - rra_out=""; rra_rc=0 - rra_out="$("$LANES_EDIT" lane-agent "$LANE" 2>/dev/null)" || rra_rc=$? - RECORDED_AGENT_READ=1 - case "$rra_rc" in - 0) RECORDED_AGENT="$rra_out" ;; - 8|2) RECORDED_AGENT="" ;; - # A READ THAT FAILED IS NOT "THIS LANE'S RECORD NAMES NO AGENT", and the - # helper says so in its own refusal: *"a caller that read it that way would - # launch the default agent over a lane another one paused"* (Amendment - # 7(d), `lanes-edit.sh`'s `lane-agent` arm). This used to `note` and launch - # `claude` anyway — the one act in this whole command that no later refusal - # can undo — so it refuses, and names the flag that goes past it, which is - # a person naming the agent rather than this command guessing at one. - *) die "\`$LANES_EDIT lane-agent $LANE\` failed (exit $rra_rc), so which agent paused this lane is UNKNOWN — and launching the default over a lane another agent paused is what Amendment 7(d) has this read refuse rather than answer. Run it by hand to see what it says, or name the agent: $prog --agent …" 2 ;; - esac - return 0 -} -if [ -n "$agent_flag" ]; then - AGENT="$agent_flag"; AGENT_EXPLICIT=1 -else - read_recorded_agent - if [ -n "$RECORDED_AGENT" ]; then AGENT="$RECORDED_AGENT"; fi - [ -n "$AGENT" ] || AGENT=claude - [ "$AGENT" = claude ] || AGENT_EXPLICIT=1 -fi -case "$AGENT" in - *[!A-Za-z0-9._-]*|'') die "--agent takes a manifest key — letters, digits, '.', '_', '-' — and '$AGENT' is not one (Amendment 17(b))" 2 ;; -esac -# THE AGENT THIS LANE IS RUNNING, IN THE ENVIRONMENT OF THE SESSION IT STARTS -# (Copilot round 2 on openRepoTools#47). `lane-handoff` derives the record's -# `agent` sub-field from `$LANES_AGENT`, else `claude` — so a `codex` session -# launched here would write `agent claude` at its own handoff and the next -# `lane-start` would resume it with the wrong launcher, one record along. The -# launch decides which agent this is; the launch is therefore what says so. -export LANES_AGENT="$AGENT" # THE AGENT'S OWN RESUMABLE ID, where the record carries one. For `claude` that # is the transcript uuid the session cell already records and this script # already resumes; for another agent it is the one id a launch has in hand, and @@ -2962,11 +2968,9 @@ fi cmd+=(${pass[@]+"${pass[@]}"}) # THE CLAUDE CODE TO START (opensoft/workBenches#119), once the agent and the -# launch are known and before the row, the object log and the handoff are -# written. `lane_claude_resolve_launch`, beside the CLAUDE_BIN default, is the -# whole of it, and the bare launch a `--confirm` answered No calls it too. +# launch are known. Resolution already ran before the binding handoff and +# first stateful act; use that selected path when building the final command. if [ "$AGENT" = claude ]; then - lane_claude_resolve_launch cmd[0]="$CLAUDE_BIN" fi diff --git a/tests/test_claude_restart_check.py b/tests/test_claude_restart_check.py index 252cfa1..63024a1 100644 --- a/tests/test_claude_restart_check.py +++ b/tests/test_claude_restart_check.py @@ -105,6 +105,25 @@ def restart(old: str, new: str) -> str: return f"RESTART NEEDED: running {old}, installed {new}; /ctx at your next breakpoint" +def newer_copy(old: str, new: str) -> str: + return (f"NEWER COPY INSTALLED: running {old}, installed {new}; " + "/ctx checks npm before selecting a version") + + +@pytest.mark.parametrize("color", [True, False]) +def test_a_newer_copy_does_not_promise_the_resolver_will_choose_it(table, color): + # The resolver prefers the npm-equal 2.1.284 over native 2.1.285 when + # npm publishes 2.1.284. This disk-only check cannot read that publication. + exe = table.npm_package("2.1.284") + table.native("2.1.285") + status_line_tree(table, str(exe)) + result = table.run("--running", "2.1.284", "--pid", "100", + **({} if color else {"NO_COLOR": "1"})) + line = newer_copy("2.1.284", "2.1.285") + assert result.stdout == (f"{GREEN}{line}{RESET}\n" if color else line + "\n") + assert "RESTART NEEDED" not in result.stdout + + def test_a_binary_npm_replaced_under_the_session_asks_for_a_restart(table): table.npm_package("2.1.284") status_line_tree(table, table.replaced_npm_exe()) @@ -123,7 +142,7 @@ def test_a_minified_package_json_is_read_too(table): '"bin":{"claude":"bin/claude.exe"},"engines":{"node":">=18"}}') status_line_tree(table, str(exe)) result = table.run("--running", "2.1.283", "--pid", "100", NO_COLOR="1") - assert result.stdout == restart("2.1.283", "2.1.284") + "\n" + assert result.stdout == newer_copy("2.1.283", "2.1.284") + "\n" def test_the_line_is_green_unless_no_color_is_set(table): @@ -144,7 +163,7 @@ def test_an_installed_version_newer_than_the_running_one_asks_even_undeleted(tab exe = table.npm_package("2.1.284") status_line_tree(table, str(exe)) result = table.run("--running", "2.1.283", "--pid", "100", NO_COLOR="1") - assert result.stdout == restart("2.1.283", "2.1.284") + "\n" + assert result.stdout == newer_copy("2.1.283", "2.1.284") + "\n" def test_a_native_session_behind_the_newest_native_version_asks(table): @@ -153,7 +172,7 @@ def test_a_native_session_behind_the_newest_native_version_asks(table): table.native("2.1.283", "2.1.284") status_line_tree(table, str(table.versions / "2.1.283")) result = table.run("--pid", "100", NO_COLOR="1") - assert result.stdout == restart("2.1.283", "2.1.284") + "\n" + assert result.stdout == newer_copy("2.1.283", "2.1.284") + "\n" def test_a_native_session_on_the_newest_version_prints_nothing(table): @@ -166,7 +185,7 @@ def test_a_running_version_that_is_not_a_version_falls_back_to_the_path(table): table.native("2.1.283", "2.1.284") status_line_tree(table, str(table.versions / "2.1.283")) result = table.run("--running", "unknown", "--pid", "100", NO_COLOR="1") - assert result.stdout == restart("2.1.283", "2.1.284") + "\n" + assert result.stdout == newer_copy("2.1.283", "2.1.284") + "\n" def test_a_replaced_binary_with_no_readable_versions_still_asks(table): @@ -200,12 +219,12 @@ def test_the_walk_starts_at_the_parent_by_default(table): def test_an_npm_session_behind_a_newer_native_install_asks(table): """Copilot on #134: the installed version was read only from the running binary's own family, so an npm 2.1.283 session beside a native 2.1.284 - said nothing, and the next launch starts the native one.""" + said nothing. The notice leaves selection to the next registry check.""" exe = table.npm_package("2.1.283") table.native("2.1.284") status_line_tree(table, str(exe)) result = table.run("--running", "2.1.283", "--pid", "100", NO_COLOR="1") - assert result.stdout == restart("2.1.283", "2.1.284") + "\n" + assert result.stdout == newer_copy("2.1.283", "2.1.284") + "\n" def test_a_native_session_behind_the_user_npm_copy_asks(table): @@ -213,7 +232,7 @@ def test_a_native_session_behind_the_user_npm_copy_asks(table): table.npm_package("2.1.284") status_line_tree(table, str(table.versions / "2.1.283")) result = table.run("--pid", "100", NO_COLOR="1") - assert result.stdout == restart("2.1.283", "2.1.284") + "\n" + assert result.stdout == newer_copy("2.1.283", "2.1.284") + "\n" def test_an_older_install_in_the_other_family_is_no_reason(table): @@ -233,7 +252,7 @@ def test_the_native_directory_is_the_one_claude_current_reads(table): status_line_tree(table, str(exe)) result = table.run("--running", "2.1.283", "--pid", "100", NO_COLOR="1", CLAUDE_CURRENT_NATIVE_DIR=str(elsewhere)) - assert result.stdout == restart("2.1.283", "2.1.290") + "\n" + assert result.stdout == newer_copy("2.1.283", "2.1.290") + "\n" def test_a_relative_user_prefix_is_read_under_home(table): @@ -245,7 +264,7 @@ def test_a_relative_user_prefix_is_read_under_home(table): (pkg / "package.json").write_text('{\n "version": "2.1.284"\n}\n') status_line_tree(table, str(table.versions / "2.1.283")) result = table.run("--pid", "100", NO_COLOR="1", NPM_CONFIG_PREFIX="rel-prefix") - assert result.stdout == restart("2.1.283", "2.1.284") + "\n" + assert result.stdout == newer_copy("2.1.283", "2.1.284") + "\n" def test_no_home_reads_only_the_running_install(table): diff --git a/tests/test_lane_start_claude_current.py b/tests/test_lane_start_claude_current.py index 9a4f4bd..5c5ced2 100644 --- a/tests/test_lane_start_claude_current.py +++ b/tests/test_lane_start_claude_current.py @@ -299,6 +299,7 @@ def test_a_refusal_ends_the_run_with_2_before_anything_is_written(box): assert box.claude_runs() == "" assert box.commits() == before assert box.lane_log() == "" + assert "rename-window" not in box.tmux_log.read_text() assert "repoZ-1" not in (box.wip / "lanes" / "LANES.md").read_text(encoding="utf-8") @@ -319,6 +320,33 @@ def test_a_resolver_that_names_nothing_runnable_ends_the_run_with_1(box, fake): assert box.commits() == before +@pytest.mark.parametrize("rc", ["1", "2"]) +@pytest.mark.parametrize("take", [["--request-handoff"], ["--force", "test takeover"]]) +def test_a_resolver_refusal_precedes_a_binding_handoff(box, rc, take): + box.resolver() + real = box.bin / "lanes-edit-real.sh" + (box.bin / "lanes-edit.sh").rename(real) + handoffs = box.root / "handoffs.log" + _write(box.bin / "lanes-edit.sh", '''#!/usr/bin/env bash +case "$1" in + binding) printf 'OtherHost\\tother-container\\t@90\\t2026-10-04T00:00:00Z\\told-session\\tlinux\\telsewhere\\tpresent\\n'; exit 0 ;; + request-handoff) printf '%s\\n' "$*" >> "$FAKE_HANDOFF_LOG"; exit 2 ;; + *) exec "$FAKE_LANES_EDIT" "$@" ;; +esac +''') + before = box.commits() + result = box.start(*take, FAKE_CC_RC=rc, FAKE_LANES_EDIT=str(real), + FAKE_HANDOFF_LOG=str(handoffs)) + assert result.returncode == int(rc), result.stderr + assert "claude-current" in result.stderr + assert box.resolver_calls() + assert not handoffs.exists(), "resolver refusal released or requested the holder" + assert "rename-window" not in box.tmux_log.read_text() + assert box.commits() == before + assert box.lane_log() == "" + assert box.claude_runs() == "" + + def test_a_launchers_verified_version_is_trusted_recorded_and_removed(box): """claude-profile ran its own claude-current and hands on all three.""" box.resolver() From 9ef4c0d09b1444d9f01e7ebc2a62cf6f66fbc93a Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:17:55 +0000 Subject: [PATCH 15/18] fix(lanes): approve handoffs before resolver preflight Read the authoritative agent after handoff while resolving a possible Claude replacement before release. Fixes #150 --- docs/README-claude-current.md | 15 ++-- lane-start | 93 +++++++++++++------------ tests/test_lane_start_claude_current.py | 52 +++++++++++--- 3 files changed, 104 insertions(+), 56 deletions(-) diff --git a/docs/README-claude-current.md b/docs/README-claude-current.md index 3bf0e3c..864fbb5 100644 --- a/docs/README-claude-current.md +++ b/docs/README-claude-current.md @@ -155,9 +155,15 @@ an inherited answer from an operator's pin: ## `lane-start` -`lane-start` applies that table after confirmation and agent selection, before -requesting a binding handoff, renaming a window or moving a transcript (and in -the bare launch described below): +`lane-start` applies that table after confirmation and any handoff approval. +Resolution precedes a binding release, window rename or transcript move (and +also runs in the bare launch described below): + +A binding held elsewhere must be approved for handoff before any resolver +call. Before releasing it, an implicit-agent handoff preflights a possible +Claude launch because the holder's new `PAUSED` record may name Claude. After +handoff, the agent is read afresh from that record. An explicit other agent +skips the Claude preflight. A declined handoff performs no update. - It looks for `claude-current` beside itself, then in `$OPENREPOTOOLS_BIN_DIR` (default `~/.local/bin`), and never on `PATH`. A @@ -176,7 +182,8 @@ the bare launch described below): - `--dry-run` prints a `PLAN` line naming the call and runs nothing, because the resolver may update. `--no-launch` resolves nothing: it is the first act inside a running session and launches nothing. -- Only the `claude` agent asks. `--agent codex` launches `codex` as before. +- An explicit other agent skips it. With a free binding, only the selected + `claude` agent asks. `--agent codex` launches `codex` as before. The lane's `STARTED` or `RESUMED` log line records the version as one more sub-field, `claude `, when a version is in hand: read by diff --git a/lane-start b/lane-start index ffdbbe9..782dabb 100755 --- a/lane-start +++ b/lane-start @@ -2091,49 +2091,6 @@ if (( confirm )); then fi fi -# Choose the agent and resolve Claude before a handoff, rename or transcript move. -# The last PAUSED agent is read once for this invocation; an explicit flag wins. -AGENT=""; AGENT_EXPLICIT=0; AGENT_ID=""; RECORDED_AGENT=""; RECORDED_AGENT_READ=0 -read_recorded_agent() { - rra_out=""; rra_rc=0 - rra_out="$("$LANES_EDIT" lane-agent "$LANE" 2>/dev/null)" || rra_rc=$? - RECORDED_AGENT_READ=1 - case "$rra_rc" in - 0) RECORDED_AGENT="$rra_out" ;; - 8|2) RECORDED_AGENT="" ;; - # A READ THAT FAILED IS NOT "THIS LANE'S RECORD NAMES NO AGENT", and the - # helper says so in its own refusal: *"a caller that read it that way would - # launch the default agent over a lane another one paused"* (Amendment - # 7(d), `lanes-edit.sh`'s `lane-agent` arm). This used to `note` and launch - # `claude` anyway — the one act in this whole command that no later refusal - # can undo — so it refuses, and names the flag that goes past it, which is - # a person naming the agent rather than this command guessing at one. - *) die "\`$LANES_EDIT lane-agent $LANE\` failed (exit $rra_rc), so which agent paused this lane is UNKNOWN — and launching the default over a lane another agent paused is what Amendment 7(d) has this read refuse rather than answer. Run it by hand to see what it says, or name the agent: $prog --agent …" 2 ;; - esac - return 0 -} -if [ -n "$agent_flag" ]; then - AGENT="$agent_flag"; AGENT_EXPLICIT=1 -else - read_recorded_agent - if [ -n "$RECORDED_AGENT" ]; then AGENT="$RECORDED_AGENT"; fi - [ -n "$AGENT" ] || AGENT=claude - [ "$AGENT" = claude ] || AGENT_EXPLICIT=1 -fi -case "$AGENT" in - *[!A-Za-z0-9._-]*|'') die "--agent takes a manifest key — letters, digits, '.', '_', '-' — and '$AGENT' is not one (Amendment 17(b))" 2 ;; -esac -# THE AGENT THIS LANE IS RUNNING, IN THE ENVIRONMENT OF THE SESSION IT STARTS -# (Copilot round 2 on openRepoTools#47). `lane-handoff` derives the record's -# `agent` sub-field from `$LANES_AGENT`, else `claude` — so a `codex` session -# launched here would write `agent claude` at its own handoff and the next -# `lane-start` would resume it with the wrong launcher, one record along. The -# launch decides which agent this is; the launch is therefore what says so. -export LANES_AGENT="$AGENT" -if [ "$AGENT" = claude ]; then - lane_claude_resolve_launch -fi - # ------------- 3b(ii). AMENDMENT 18(b): ONE BINDING PER LANE, AND THIS PLACE # # *"A bound lane is started nowhere else — not a second window on the same host, @@ -2222,6 +2179,13 @@ if [ "$bind_rc" = 0 ]; then which makes that place STOP loudly rather than silently: $prog --force \"\" $self_args" 2 fi + # Approval precedes any possible update; resolution precedes release. + # With no explicit agent the holder's new PAUSED may name Claude, so + # preflight that possible launch before asking it to give up the binding. + # An explicit other agent never needs a Claude preflight. + if [ -z "$agent_flag" ] || [ "$agent_flag" = claude ]; then + lane_claude_resolve_launch + fi ls_rh=("$LANES_EDIT" request-handoff "$LANE") [ -z "$win_sid" ] || ls_rh+=(--session "$win_sid") if [ "$ls_ask" = force ]; then @@ -2299,6 +2263,49 @@ if [ "$bind_recheck" = 1 ]; then esac fi +# The successful handoff may have published a different agent in its PAUSED +# record. Read that authoritative answer now, before rename or transcript movement. +AGENT=""; AGENT_EXPLICIT=0; AGENT_ID=""; RECORDED_AGENT=""; RECORDED_AGENT_READ=0 +read_recorded_agent() { + rra_out=""; rra_rc=0 + rra_out="$("$LANES_EDIT" lane-agent "$LANE" 2>/dev/null)" || rra_rc=$? + RECORDED_AGENT_READ=1 + case "$rra_rc" in + 0) RECORDED_AGENT="$rra_out" ;; + 8|2) RECORDED_AGENT="" ;; + # A READ THAT FAILED IS NOT "THIS LANE'S RECORD NAMES NO AGENT", and the + # helper says so in its own refusal: *"a caller that read it that way would + # launch the default agent over a lane another one paused"* (Amendment + # 7(d), `lanes-edit.sh`'s `lane-agent` arm). This used to `note` and launch + # `claude` anyway — the one act in this whole command that no later refusal + # can undo — so it refuses, and names the flag that goes past it, which is + # a person naming the agent rather than this command guessing at one. + *) die "\`$LANES_EDIT lane-agent $LANE\` failed (exit $rra_rc), so which agent paused this lane is UNKNOWN — and launching the default over a lane another agent paused is what Amendment 7(d) has this read refuse rather than answer. Run it by hand to see what it says, or name the agent: $prog --agent …" 2 ;; + esac + return 0 +} +if [ -n "$agent_flag" ]; then + AGENT="$agent_flag"; AGENT_EXPLICIT=1 +else + read_recorded_agent + if [ -n "$RECORDED_AGENT" ]; then AGENT="$RECORDED_AGENT"; fi + [ -n "$AGENT" ] || AGENT=claude + [ "$AGENT" = claude ] || AGENT_EXPLICIT=1 +fi +case "$AGENT" in + *[!A-Za-z0-9._-]*|'') die "--agent takes a manifest key — letters, digits, '.', '_', '-' — and '$AGENT' is not one (Amendment 17(b))" 2 ;; +esac +# THE AGENT THIS LANE IS RUNNING, IN THE ENVIRONMENT OF THE SESSION IT STARTS +# (Copilot round 2 on openRepoTools#47). `lane-handoff` derives the record's +# `agent` sub-field from `$LANES_AGENT`, else `claude` — so a `codex` session +# launched here would write `agent claude` at its own handoff and the next +# `lane-start` would resume it with the wrong launcher, one record along. The +# launch decides which agent this is; the launch is therefore what says so. +export LANES_AGENT="$AGENT" +if [ "$AGENT" = claude ]; then + lane_claude_resolve_launch +fi + # ------------------------------------------------------- 4. name the window # Step 3 fetched the published register. Before changing a tmux name, prove diff --git a/tests/test_lane_start_claude_current.py b/tests/test_lane_start_claude_current.py index 5c5ced2..0d91578 100644 --- a/tests/test_lane_start_claude_current.py +++ b/tests/test_lane_start_claude_current.py @@ -320,23 +320,34 @@ def test_a_resolver_that_names_nothing_runnable_ends_the_run_with_1(box, fake): assert box.commits() == before -@pytest.mark.parametrize("rc", ["1", "2"]) -@pytest.mark.parametrize("take", [["--request-handoff"], ["--force", "test takeover"]]) -def test_a_resolver_refusal_precedes_a_binding_handoff(box, rc, take): - box.resolver() +def _bound_elsewhere(box): real = box.bin / "lanes-edit-real.sh" (box.bin / "lanes-edit.sh").rename(real) handoffs = box.root / "handoffs.log" _write(box.bin / "lanes-edit.sh", '''#!/usr/bin/env bash case "$1" in - binding) printf 'OtherHost\\tother-container\\t@90\\t2026-10-04T00:00:00Z\\told-session\\tlinux\\telsewhere\\tpresent\\n'; exit 0 ;; - request-handoff) printf '%s\\n' "$*" >> "$FAKE_HANDOFF_LOG"; exit 2 ;; - *) exec "$FAKE_LANES_EDIT" "$@" ;; + binding) + [ ! -e "$FAKE_HANDOFF_LOG" ] || exit 8 + printf 'OtherHost\\tother-container\\t@90\\t2026-10-04T00:00:00Z\\told-session\\tlinux\\telsewhere\\tpresent\\n'; exit 0 ;; + request-handoff) printf '%s\\n' "$*" >> "$FAKE_HANDOFF_LOG"; exit "${FAKE_HANDOFF_RC:-2}" ;; + lane-agent) + if [ -e "$FAKE_HANDOFF_LOG" ] && [ -n "${FAKE_HANDOFF_AGENT:-}" ]; then + printf '%s\\n' "$FAKE_HANDOFF_AGENT"; exit 0 + fi ;; esac +exec "$FAKE_LANES_EDIT" "$@" ''') + box.env.update(FAKE_LANES_EDIT=str(real), FAKE_HANDOFF_LOG=str(handoffs)) + return handoffs + + +@pytest.mark.parametrize("rc", ["1", "2"]) +@pytest.mark.parametrize("take", [["--request-handoff"], ["--force", "test takeover"]]) +def test_a_resolver_refusal_precedes_a_binding_handoff(box, rc, take): + box.resolver() + handoffs = _bound_elsewhere(box) before = box.commits() - result = box.start(*take, FAKE_CC_RC=rc, FAKE_LANES_EDIT=str(real), - FAKE_HANDOFF_LOG=str(handoffs)) + result = box.start(*take, FAKE_CC_RC=rc) assert result.returncode == int(rc), result.stderr assert "claude-current" in result.stderr assert box.resolver_calls() @@ -347,6 +358,29 @@ def test_a_resolver_refusal_precedes_a_binding_handoff(box, rc, take): assert box.claude_runs() == "" +def test_a_declined_handoff_resolves_and_updates_nothing(box): + box.resolver() + handoffs = _bound_elsewhere(box) + before = box.commits() + result = box.start(LANE_START_HANDOFF_ANSWER="n") + assert result.returncode == 2, result.stderr + assert "ONE binding" in result.stderr + assert box.resolver_calls() == "" + assert not handoffs.exists() + assert "rename-window" not in box.tmux_log.read_text() + assert box.commits() == before + + +def test_the_agent_is_read_from_the_completed_handoff(box): + box.resolver() + _bound_elsewhere(box) + _write(box.fakebin / "codex", FAKE_CLAUDE) + result = box.start("--request-handoff", FAKE_HANDOFF_RC="0", + FAKE_HANDOFF_AGENT="codex") + assert result.returncode == 0, result.stderr + assert box.claude_runs().startswith(f"ran {box.fakebin / 'codex'}: ") + + def test_a_launchers_verified_version_is_trusted_recorded_and_removed(box): """claude-profile ran its own claude-current and hands on all three.""" box.resolver() From 6ed41896c1b3701b7da44e20dc6711e41ba309fc Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:19:09 +0000 Subject: [PATCH 16/18] fix(lanes): reject directories in resolved Claude paths Fixes #151 --- lane-start | 2 +- tests/test_lane_start_claude_current.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lane-start b/lane-start index 782dabb..01f6bb2 100755 --- a/lane-start +++ b/lane-start @@ -683,7 +683,7 @@ lane_claude_resolve_launch() { $lcc_out LCC case "$lcc_path" in - /*) [ -x "$lcc_path" ] || + /*) [ -f "$lcc_path" ] && [ -x "$lcc_path" ] || die "claude-current answered '$lcc_path', which is not an executable file. Nothing was written." 1 ;; *) die "claude-current exited 0 without an absolute path= line (it printed '$lcc_out'). Nothing was written." 1 ;; esac diff --git a/tests/test_lane_start_claude_current.py b/tests/test_lane_start_claude_current.py index 0d91578..dc7a9b3 100644 --- a/tests/test_lane_start_claude_current.py +++ b/tests/test_lane_start_claude_current.py @@ -309,6 +309,7 @@ def test_a_refusal_ends_the_run_with_2_before_anything_is_written(box): {"FAKE_CC_RAW": "not porcelain"}, {"FAKE_CC_PATH": "relative/claude"}, {"FAKE_CC_PATH": "/nonexistent/claude-current-test/claude"}, + {"FAKE_CC_PATH": "/"}, ]) def test_a_resolver_that_names_nothing_runnable_ends_the_run_with_1(box, fake): box.resolver() From 2110ff7b8a8cfff6d2dc7c5c08d3093e454aaad4 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:38:23 +0000 Subject: [PATCH 17/18] fix(claude): recheck bindings and reject watchdog timeout output Recheck the binding immediately before window rename after resolver waits. A watchdog timeout stays 124 even when the command traps TERM and exits successfully. Fixes #152 --- claude-current | 3 + docs/README-claude-current.md | 3 + lane-start | 129 +++++++++++++----------- tests/test_claude_current.py | 17 ++++ tests/test_lane_start_claude_current.py | 24 +++++ 5 files changed, 119 insertions(+), 57 deletions(-) diff --git a/claude-current b/claude-current index 84bc840..cd7f9f7 100755 --- a/claude-current +++ b/claude-current @@ -257,6 +257,9 @@ run_bounded() { wait "$pid" || rc=$? if mkdir -- "$flag" 2>/dev/null || [ ! -d "$flag" ]; then kill_tree "$dog" >/dev/null + else + # The watchdog won even if TERM was handled as a successful exit. + rc=124 fi wait "$dog" 2>/dev/null || : rmdir -- "$flag" 2>/dev/null || : diff --git a/docs/README-claude-current.md b/docs/README-claude-current.md index 864fbb5..884f215 100644 --- a/docs/README-claude-current.md +++ b/docs/README-claude-current.md @@ -164,6 +164,9 @@ call. Before releasing it, an implicit-agent handoff preflights a possible Claude launch because the holder's new `PAUSED` record may name Claude. After handoff, the agent is read afresh from that record. An explicit other agent skips the Claude preflight. A declined handoff performs no update. +After resolution, the binding is read again immediately before the window +rename. A lane taken or changed during the update wait is refused; an unchanged +existing binding can continue. - It looks for `claude-current` beside itself, then in `$OPENREPOTOOLS_BIN_DIR` (default `~/.local/bin`), and never on `PATH`. A diff --git a/lane-start b/lane-start index 01f6bb2..168a40a 100755 --- a/lane-start +++ b/lane-start @@ -616,6 +616,7 @@ PROJECTS_STATE="${CLAUDE_PROJECTS_DIR:-$CLAUDE_HOME/projects}" # session: a launch from inside that session must resolve again. lane_claude_version="" lane_claude_resolve=0 +lane_claude_checked=0 if [ -z "${CLAUDE_BIN:-}" ]; then lane_claude_resolve=1 elif [ "$CLAUDE_BIN" = "${CLAUDE_RESOLVED_BIN:-}" ]; then @@ -691,6 +692,7 @@ LCC CLAUDE_RESOLVED_BIN="$lcc_path" export CLAUDE_BIN CLAUDE_RESOLVED_BIN lane_claude_version="$lcc_version" + lane_claude_checked=1 step "launching claude ${lcc_version:-of an unknown version} (${lcc_status:-no status}) at $lcc_path, as claude-current chose" } @@ -2206,63 +2208,6 @@ if [ "$bind_rc" = 0 ]; then fi fi -# ---------------- 3d. AND THE BINDING IS READ AGAIN BEFORE THE FIRST ACT -# -# A HANDOFF THAT LANDED IS AN OBSERVATION AND NOT A RESERVATION (Copilot round 1 -# on opensoft/openRepoTools#83). `request-handoff` returns 0 when it SEES the -# release; between that and the rename, the row status, the log line and the -# launch, another place waiting on the same release can bind the lane — and -# `commit_push` rebases both append-only writes rather than refusing the second, -# so the log would carry two bindings and no reader could say which is the -# lane's. -# -# NOTHING IN THE RATIFIED TEXT GIVES A LANE A RESERVATION, and inventing one -# here would be an unratified act on an in-force protocol — it is filed rather -# than smuggled in (opensoft/openRepoTools#84). What IS available is to make the -# read the LAST thing before the first act instead of the first thing in the -# run: the window between them is then one read wide rather than a question, a -# rename, two register writes and a launch wide, and a lane that moved in it is -# a refusal naming where it went rather than a second binding written over it. -# -# ONLY ON THE PATH THAT WAITED. Every other run has just read the binding a few -# lines above and has asked nobody anything since; a second fetch on every lane -# start would be a network round-trip bought for a window that is already this -# narrow. -# AND A DRY RUN HAS NOTHING TO RE-READ, BECAUSE IT DID NOTHING (Copilot round 3 -# on opensoft/openRepoTools#83). `request-handoff --dry-run` prints its plan and -# answers 0 WITHOUT releasing anything, so an unconditional re-read here sees the -# binding still standing and refuses — and `lane-start --dry-run -# --request-handoff`, whose whole job is to print the plan, could never print -# one. A plan is a statement about what WOULD happen; the fence that protects -# the first act belongs to the run that takes it. -if [ "$bind_recheck" = 1 ] && (( dry_run )); then - plan "read the binding again before the rename, and refuse if the lane moved while this run was taking it" - bind_recheck=0 -fi -if [ "$bind_recheck" = 1 ]; then - : > "$LS_TMP" - bind_again=""; bind_again_rc=0 - bind_again="$("$LANES_EDIT" binding "$LANE" 2>"$LS_TMP")" || bind_again_rc=$? - [ -s "$LS_TMP" ] && cat -- "$LS_TMP" >&2 - case "$bind_again_rc" in - 8) step "binding: still free after the handoff — taking it" ;; - 0) - IFS=$'\t' read -r ba_host ba_cont ba_win ba_utc ba_sess ba_os ba_where ba_wstate <<<"$bind_again" - # ANY BINDING SEEN HERE IS THE RACE, INCLUDING ONE AT THIS VERY WINDOW - # (Copilot round 6 on opensoft/openRepoTools#83). This run has written no - # `STARTED`/`RESUMED` of its own yet — that is three steps further on — - # and it reached this read only by asking a binding ELSEWHERE to release, - # so a binding standing at this window now cannot be this invocation's. - # It can only be a CONCURRENT one that bound while this one waited, which - # is the two-processes-one-lane this re-read exists to catch; accepting it - # by window equality let both carry on and launch. - die "lane $LANE was released and BOUND AGAIN while this run was taking it: it is now window ${ba_win:-none} in container ${ba_cont:-none} on host ${ba_host:-unknown} (session ${ba_sess:-unknown}, ${ba_utc:-unknown}). The handoff this run asked for LANDED — that place did hand off — and somebody else bound the lane before this run reached its first act. This run has written no binding of its own yet, so that one is not this invocation's: a binding at THIS window is a second invocation IN it, which is the same race one pane closer. Nothing has been renamed, written or launched. Ask again, or take it up with that place: - $prog --request-handoff $self_args" 2 ;; - 2) note "this \`lanes-edit.sh\` has no \`binding\`, so whether the lane moved again while this run waited is not known here; carrying on." ;; - *) die "$LANES_EDIT binding $LANE exited $bind_again_rc on the re-read before the rename, so whether this lane is still free could NOT be established — and that is not 'it is'. Nothing has been renamed, written or launched." 1 ;; - esac -fi - # The successful handoff may have published a different agent in its PAUSED # record. Read that authoritative answer now, before rename or transcript movement. AGENT=""; AGENT_EXPLICIT=0; AGENT_ID=""; RECORDED_AGENT=""; RECORDED_AGENT_READ=0 @@ -2345,6 +2290,76 @@ $name_rows EOF fi +# ---------------- 3d. AND THE BINDING IS READ AGAIN BEFORE THE FIRST ACT +# +# A HANDOFF THAT LANDED IS AN OBSERVATION AND NOT A RESERVATION (Copilot round 1 +# on opensoft/openRepoTools#83). `request-handoff` returns 0 when it SEES the +# release; between that and the rename, the row status, the log line and the +# launch, another place waiting on the same release can bind the lane — and +# `commit_push` rebases both append-only writes rather than refusing the second, +# so the log would carry two bindings and no reader could say which is the +# lane's. +# +# NOTHING IN THE RATIFIED TEXT GIVES A LANE A RESERVATION, and inventing one +# here would be an unratified act on an in-force protocol — it is filed rather +# than smuggled in (opensoft/openRepoTools#84). What IS available is to make the +# read the LAST thing before the first act instead of the first thing in the +# run: the window between them is then one read wide rather than a question, a +# rename, two register writes and a launch wide, and a lane that moved in it is +# a refusal naming where it went rather than a second binding written over it. +# +# A RESOLVER MAY WAIT TOO: another start can bind while this one waits for +# the update lock or npm. Recheck after resolution, immediately before rename, +# including an initially free binding. An unchanged existing binding remains +# valid; an observed release or replacement is not this invocation's binding. +# AND A DRY RUN HAS NOTHING TO RE-READ, BECAUSE IT DID NOTHING (Copilot round 3 +# on opensoft/openRepoTools#83). `request-handoff --dry-run` prints its plan and +# answers 0 WITHOUT releasing anything, so an unconditional re-read here sees the +# binding still standing and refuses — and `lane-start --dry-run +# --request-handoff`, whose whole job is to print the plan, could never print +# one. A plan is a statement about what WOULD happen; the fence that protects +# the first act belongs to the run that takes it. +bind_waited="$bind_recheck" +bind_expected_rc="$bind_rc"; bind_expected_out="$bind_out" +if [ "$bind_recheck" = 1 ]; then + bind_expected_rc=8; bind_expected_out="" +fi +[ "$lane_claude_checked" = 0 ] || bind_recheck=1 +if [ "$bind_recheck" = 1 ] && (( dry_run )); then + plan "read the binding again before the rename, and refuse if the lane moved while this run was taking it" + bind_recheck=0 +fi +if [ "$bind_recheck" = 1 ]; then + : > "$LS_TMP" + bind_again=""; bind_again_rc=0 + bind_again="$("$LANES_EDIT" binding "$LANE" 2>"$LS_TMP")" || bind_again_rc=$? + [ -s "$LS_TMP" ] && cat -- "$LS_TMP" >&2 + case "$bind_again_rc" in + 8) [ "$bind_expected_rc" = 8 ] || + die "lane $LANE's binding changed while Claude was resolved: it is now released. Nothing here was renamed or bound; retry against the current record." 2 + if [ "$bind_waited" = 1 ]; then + step "binding: still free after the handoff — taking it" + else + step "binding: still free after Claude resolution — taking it" + fi ;; + 0) + if [ "$bind_expected_rc" = 0 ] && [ "$bind_again" = "$bind_expected_out" ]; then + step "binding: unchanged after Claude resolution" + else + IFS=$'\t' read -r ba_host ba_cont ba_win ba_utc ba_sess ba_os ba_where ba_wstate <<<"$bind_again" + # A NEW OR CHANGED BINDING IS THE RACE, INCLUDING ONE IN THIS WINDOW. + # This invocation wrote no STARTED/RESUMED yet. An unchanged prior + # binding was accepted above; this one appeared during the wait. + die "lane $LANE was BOUND AGAIN or changed while this run was taking it: it is now window ${ba_win:-none} in container ${ba_cont:-none} on host ${ba_host:-unknown} (session ${ba_sess:-unknown}, ${ba_utc:-unknown}). This run observed a free or earlier binding before its wait, and another binding now stands before its first act. This run has written no binding of its own yet, so that one is not this invocation's: a binding at THIS window is a second invocation IN it, which is the same race one pane closer. Nothing has been renamed, written or launched. Ask again, or take it up with that place: + $prog --request-handoff $self_args" 2 + fi ;; + 2) [ "$bind_expected_rc" = 2 ] || + die "the binding could not be rechecked after Claude resolution (exit 2). Nothing here was renamed or bound." 1 + note "this \`lanes-edit.sh\` has no \`binding\`, so whether the lane moved again while this run waited is not known here; carrying on." ;; + *) die "$LANES_EDIT binding $LANE exited $bind_again_rc on the re-read before the rename, so whether this lane is still free could NOT be established — and that is not 'it is'. Nothing has been renamed, written or launched." 1 ;; + esac +fi + if (( dry_run )); then plan "tmux rename-window $LANE (also turns automatic-rename off for this window)" else diff --git a/tests/test_claude_current.py b/tests/test_claude_current.py index e116b92..b77ffae 100644 --- a/tests/test_claude_current.py +++ b/tests/test_claude_current.py @@ -530,6 +530,23 @@ def test_the_watchdog_ends_a_hung_commands_children_too(box): assert "npm view took longer than 1s" in result.stderr +def test_the_watchdog_rejects_output_when_term_is_handled_as_success(box): + box.user_copy("2.1.284") + (box.bin / "npm").write_text('''#!/usr/bin/env bash +trap 'exit 0' TERM +printf '2.1.284\\n' +sleep 30 +''') + started = time.monotonic() + result = box.run("--porcelain", CLAUDE_CURRENT_TIMEOUT="1", + PATH=path_without(box, "timeout", "gtimeout")) + assert time.monotonic() - started < 15 + assert result.returncode == 0, result.stderr + assert porcelain(result)["status"] == "unverified" + assert porcelain(result)["published"] == "" + assert "npm view took longer than 1s" in result.stderr + + @pytest.mark.parametrize("fallback", [False, True], ids=["host", "watchdog"]) def test_a_command_that_ignores_term_is_killed_after_the_grace(box, fallback): """Copilot on #134: TERM was the only signal, so a command that ignored it diff --git a/tests/test_lane_start_claude_current.py b/tests/test_lane_start_claude_current.py index dc7a9b3..640107e 100644 --- a/tests/test_lane_start_claude_current.py +++ b/tests/test_lane_start_claude_current.py @@ -382,6 +382,30 @@ def test_the_agent_is_read_from_the_completed_handoff(box): assert box.claude_runs().startswith(f"ran {box.fakebin / 'codex'}: ") +@pytest.mark.parametrize("place", ["here", "elsewhere"]) +def test_a_lane_bound_during_resolution_is_not_taken(box, place): + box.resolver() + real = box.bin / "lanes-edit-real.sh" + (box.bin / "lanes-edit.sh").rename(real) + _write(box.bin / "lanes-edit.sh", '''#!/usr/bin/env bash +if [ "$1" = binding ]; then + [ -s "$FAKE_CC_LOG" ] || exit 8 + printf 'Eagle\\tpy-bench\\t@90\\t2026-10-04T00:00:00Z\\tother-session\\tlinux\\t%s\\tpresent\\n' "$FAKE_BINDING_PLACE" + exit 0 +fi +exec "$FAKE_LANES_EDIT" "$@" +''') + before = box.commits() + result = box.start(FAKE_LANES_EDIT=str(real), FAKE_BINDING_PLACE=place) + assert result.returncode == 2, result.stderr + assert "BOUND AGAIN or changed" in result.stderr + assert box.resolver_calls() + assert "rename-window" not in box.tmux_log.read_text() + assert box.commits() == before + assert box.lane_log() == "" + assert box.claude_runs() == "" + + def test_a_launchers_verified_version_is_trusted_recorded_and_removed(box): """claude-profile ran its own claude-current and hands on all three.""" box.resolver() From 11037f01a45a72fa3fc9a8a2b0e24a64cbf1ed12 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:08:14 +0000 Subject: [PATCH 18/18] fix(claude): contain timed-out children and preflight local fences Run the watchdog command in a dedicated process group so TERM-handler children remain covered by the final KILL. Refuse known retired lanes and new-window name collisions before resolver work or a handoff, and recheck those fences after resolution before rename. Validation: 463 focused tests passed, 411 deselected in 195.30s through tests/run.sh in py-bench. Fixes #153. --- claude-current | 30 +++++---- docs/README-claude-current.md | 5 +- lane-start | 81 ++++++++++++++----------- tests/test_claude_current.py | 17 ++++++ tests/test_lane_start_claude_current.py | 66 ++++++++++++++++++++ 5 files changed, 145 insertions(+), 54 deletions(-) diff --git a/claude-current b/claude-current index cd7f9f7..89b61e1 100755 --- a/claude-current +++ b/claude-current @@ -201,10 +201,8 @@ kill_tree() { kill -CONT "$1" 2>/dev/null || : } -# running : 0 while has not exited. A zombie has exited and waits -# only to be reaped, which in a container whose first process reaps nothing -# can be for ever, so it is not running. Without `ps`, a pid that answers -# `kill -0` is taken as running. +# running : zombies have exited, even before their parent reaps them. +# The installation lock uses this to distinguish a live owner from a dead one. running() { local st kill -0 "$1" 2>/dev/null || return 1 @@ -217,14 +215,14 @@ running() { # when it ran out of time. GNU `timeout`, else Homebrew's `gtimeout`, else a # watchdog, because a stock macOS ships neither. Every branch sends TERM at # the bound and KILL $KILL_GRACE seconds later to what is still running: -# `-k` for the two commands, and for the watchdog the whole tree it signalled. +# `-k` for the two commands, and for the watchdog a dedicated process group. # The watchdog ends the WHOLE tree, as `timeout` ends its process group: a # command's own children (npm's lifecycle processes, a script's `sleep`) # would otherwise keep the caller's command substitution open after the # command itself was killed. The watchdog's own output goes to /dev/null for # the same reason. run_bounded() { - local secs="$1" pid dog rc=0 flag + local secs="$1" pid dog rc=0 flag monitor=0 shift if command -v timeout >/dev/null 2>&1; then timeout -k "$KILL_GRACE" "$secs" "$@" || rc=$? @@ -237,21 +235,21 @@ run_bounded() { # watchdog that wakes after the command ended signals nothing. Where no # flag can be made at all, the watchdog acts and this function stops it. flag="${TMPDIR:-/tmp}/claude-current.bound.$$.$RANDOM" + # Bash job control assigns this asynchronous command its own process + # group on both Linux and stock macOS. Keep the caller's setting, and + # signal the group even after its leader exits: a TERM handler may + # create another child that still owns the output pipe. + case "$-" in *m*) monitor=1 ;; esac + set -m "$@" & pid=$! + [ "$monitor" = 1 ] || set +m ( sleep "$secs" if ! mkdir -- "$flag" 2>/dev/null && [ -d "$flag" ]; then exit 0; fi - tree="$(kill_tree "$pid")" - n=0 - while [ "$n" -lt "$KILL_GRACE" ]; do - alive="" - for p in $tree; do running "$p" && alive=1; done - [ -n "$alive" ] || exit 0 - sleep 1 - n=$((n + 1)) - done - for p in $tree; do kill -KILL "$p" 2>/dev/null || :; done + kill -TERM -- "-$pid" 2>/dev/null || exit 0 + sleep "$KILL_GRACE" + kill -KILL -- "-$pid" 2>/dev/null || : ) >/dev/null 2>&1 & dog=$! wait "$pid" || rc=$? diff --git a/docs/README-claude-current.md b/docs/README-claude-current.md index 884f215..03687b9 100644 --- a/docs/README-claude-current.md +++ b/docs/README-claude-current.md @@ -127,8 +127,9 @@ apart from 1 because a launcher reports 1 as "no Claude Code". | `CLAUDE_CURRENT_SYSTEM_CANDIDATES` | `/usr/local/bin/claude:/usr/bin/claude` | the image's copies | The timeouts use `timeout`, else Homebrew's `gtimeout`, else a watchdog of its -own, because a stock macOS ships neither. The watchdog ends the command's whole -process tree, as `timeout` ends its process group, so a child npm started +own, because a stock macOS ships neither. The watchdog starts the command in its +own process group and signals that group, including children created by a TERM +handler after the timeout, so a child npm started cannot hold the answer open past the bound. On every branch a command that ignores TERM is sent KILL five seconds later. diff --git a/lane-start b/lane-start index 168a40a..346be23 100755 --- a/lane-start +++ b/lane-start @@ -2093,6 +2093,48 @@ if (( confirm )); then fi fi +# Refuse known local conflicts before resolving or updating Claude, and +# before asking a holder to release its binding. +lane_check_local_fences() { +# Step 3 fetched the published register. Before changing a tmux name, prove +# this identity was not retired (including an unpushed local archive row). +retired_rc=0 +retired_hits="$(LANES_NO_FETCH=1 "$LANES_EDIT" retired-identity "$LANE" 2>/dev/null)" || retired_rc=$? +case "$retired_rc" in + 8) : ;; + 0) die "lane $LANE is RETIRED (archive: $retired_hits). Nothing was renamed or written; choose a free position." 2 ;; + *) die "the lane archive could not be checked for $LANE (exit $retired_rc). Nothing was renamed or written." 1 ;; +esac + +# A lane name is a tmux-window address. tmux itself allows two distinct +# windows to carry one name, so check the whole server before taking it. A +# linked window may appear in several sessions with the same @id; that is one +# window, not a collision. +# +# ASKED OF A NEW LANE ONLY — one with no row — because only there is every +# other window carrying the name certainly FOREIGN: nothing has bound that lane +# yet. An EXISTING lane's own binding is different since Amendment 18 (#83): +# a bench in another container shares this tmux server, its window keeps the +# lane's name until that session hands off, and a second place binding the +# lane after a release, or through `--request-handoff`, is the ratified flow. +# Asked of every lane, this fence refused exactly that takeover. +if [ "$row_count" = 0 ]; then + name_tab="$(printf '\t')" + name_rows="$(tmux list-windows -a -F "#{window_id}${name_tab}#{window_name}" 2>/dev/null)" || + die "tmux could not list window names, so whether $LANE is already in use is unknown. Nothing was renamed or written." 2 + while IFS="$name_tab" read -r name_id name_value; do + [ -n "$name_id" ] || continue + if [ "$name_id" != "$this_window_id" ] && + [ "$(printf '%s' "$name_value" | tr 'A-Z' 'a-z')" = "$(printf '%s' "$LANE" | tr 'A-Z' 'a-z')" ]; then + die "another tmux window ($name_id) is already named $name_value, and $LANE is a NEW lane, so that window is not its own. Lane window names must be unique in this tmux server; nothing was renamed or written." 2 + fi + done </dev/null)" || retired_rc=$? -case "$retired_rc" in - 8) : ;; - 0) die "lane $LANE is RETIRED (archive: $retired_hits). Nothing was renamed or written; choose a free position." 2 ;; - *) die "the lane archive could not be checked for $LANE (exit $retired_rc). Nothing was renamed or written." 1 ;; -esac - -# A lane name is a tmux-window address. tmux itself allows two distinct -# windows to carry one name, so check the whole server before taking it. A -# linked window may appear in several sessions with the same @id; that is one -# window, not a collision. -# -# ASKED OF A NEW LANE ONLY — one with no row — because only there is every -# other window carrying the name certainly FOREIGN: nothing has bound that lane -# yet. An EXISTING lane's own binding is different since Amendment 18 (#83): -# a bench in another container shares this tmux server, its window keeps the -# lane's name until that session hands off, and a second place binding the -# lane after a release, or through `--request-handoff`, is the ratified flow. -# Asked of every lane, this fence refused exactly that takeover. -if [ "$row_count" = 0 ]; then - name_tab="$(printf '\t')" - name_rows="$(tmux list-windows -a -F "#{window_id}${name_tab}#{window_name}" 2>/dev/null)" || - die "tmux could not list window names, so whether $LANE is already in use is unknown. Nothing was renamed or written." 2 - while IFS="$name_tab" read -r name_id name_value; do - [ -n "$name_id" ] || continue - if [ "$name_id" != "$this_window_id" ] && - [ "$(printf '%s' "$name_value" | tr 'A-Z' 'a-z')" = "$(printf '%s' "$LANE" | tr 'A-Z' 'a-z')" ]; then - die "another tmux window ($name_id) is already named $name_value, and $LANE is a NEW lane, so that window is not its own. Lane window names must be unique in this tmux server; nothing was renamed or written." 2 - fi - done <