From eb617798c850aa06712ad89c97cb1cdd75b8c6ac Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Mon, 17 Aug 2026 12:22:17 -0400 Subject: [PATCH 1/9] fix dependency-only provisioning --- bin/fm-provision-lib.sh | 185 +++++++++++++++++++++++++------ tests/fm-spawn-provision.test.sh | 174 ++++++++++++++++++++++++++++- 2 files changed, 319 insertions(+), 40 deletions(-) diff --git a/bin/fm-provision-lib.sh b/bin/fm-provision-lib.sh index 22391312d1c..e0f85a94f00 100755 --- a/bin/fm-provision-lib.sh +++ b/bin/fm-provision-lib.sh @@ -20,8 +20,10 @@ # FM_PROVISION_MAX_COMPONENTS budget allows, a component whose directory lies # deeper below the worktree root than FM_PROVISION_SCAN_DEPTH, a Python # component declaring a pyproject.toml but neither a uv.lock nor a -# requirements.txt, a recognized-but-unsupported -# package manager (yarn, bun), a JS component whose package manager is neither +# requirements.txt, a standalone uv.lock whose root dependency set cannot be +# identified without a pyproject.toml, a JS lockfile without a package.json +# manifest, a recognized-but-unsupported package manager (yarn, bun), a JS +# component whose package manager is neither # named by package.json's packageManager field nor implied by a single # lockfile, a declared Node major that cannot be found under the standard # version-manager directories, components declaring conflicting Node majors, @@ -84,12 +86,22 @@ # does not recognize provisions nothing. # # SUPPORTED ECOSYSTEMS (one component per directory per language) -# uv uv.lock -> uv sync --frozen -# pip requirements.txt -> uv venv --clear .venv + uv pip install -r ... -# npm package-lock.json -> npm ci -# pnpm pnpm-lock.yaml -> pnpm install --frozen-lockfile +# uv uv.lock + pyproject.toml -> uv sync --frozen +# uv-lock uv.lock only -> synthetic metadata outside the worktree +# + uv sync --frozen --no-install-project +# pip requirements.txt -> uv venv --clear .venv +# + uv pip install -r ... +# npm package-lock.json -> npm ci +# pnpm pnpm-lock.yaml -> pnpm install --frozen-lockfile # Python always goes through uv, never pip/venv directly (AGENTS.md toolchain -# convention). A directory whose pyproject.toml declares a project - a [project], +# convention). A requirements.txt without package metadata is only a dependency +# list, so it creates an environment and installs those requirements without +# building the directory. A standalone uv.lock is also only a dependency list. +# Its root package name is read from the lock, matching synthetic project +# metadata is created under the provisioning cache, and uv is told not to +# install that synthetic project or any workspace package. The real component +# receives only the resulting .venv and is never presented to uv as a project. +# A directory whose pyproject.toml declares a project - a [project], # [build-system], or [tool.poetry] table - with neither of those # two Python manifests is a capability gap: choosing an installer for a lockless # project is a design decision this library has not made, and provisioning @@ -102,12 +114,13 @@ # buys on the one surface built for signal. # The JS package manager is read from what the project DECLARES - # package.json's corepack `packageManager` field - and only falls back to the -# lockfile when the project declares nothing; lockfile-filename precedence is -# convention, not evidence, and a directory carrying two committed lockfiles -# would otherwise be resolved by this library's opinion rather than by what the -# project actually installs with. yarn and bun, and a JS component whose -# manager cannot be determined, are capability gaps: they are left -# unprovisioned and reported, never installed with a guessed installer. +# lockfile when package.json declares no manager; lockfile-filename precedence +# is convention, not evidence, and a directory carrying two committed +# lockfiles would otherwise be resolved by this library's opinion rather than +# by what the project actually installs with. A JS lockfile without +# package.json, yarn and bun, and a JS component whose manager cannot be +# determined are capability gaps: they are left unprovisioned and reported, +# never installed with a guessed or incomplete declaration. # # CACHING. Every component carries a fingerprint over its own manifests - the # installer's configuration files (.npmrc, uv.toml) among them, because they @@ -403,21 +416,57 @@ fm_provision_scan() { # [ "${#deep[@]}" -eq 0 ] || printf 'below-depth %s\n' "${deep[@]}" } +# A standalone uv.lock has no project file for uv to discover, but the lock +# still names the root whose dependency edges select the environment. Return +# that one root package name only when uv's generated lock shape identifies it +# unambiguously through a virtual or editable source at ".". An unreadable, +# malformed, or rootless lock returns non-zero so detection can record a gap +# instead of attempting a project sync that must fail. +fm_provision_uv_lock_root_name() { # + local dir=$1 + fm_provision_run_bounded "$FM_PROVISION_PROBE_TIMEOUT" "$dir" \ + python3 - "$dir/uv.lock" <<'PY' +import re +import sys + +try: + text = open(sys.argv[1], encoding="utf-8").read() +except (OSError, UnicodeDecodeError): + raise SystemExit(1) + +blocks = re.split(r"(?m)^\s*\[\[package\]\]\s*(?:#.*)?$", text)[1:] +roots = [] +for block in blocks: + name = re.search(r'(?m)^\s*name\s*=\s*"([A-Za-z0-9][A-Za-z0-9._-]*)"\s*(?:#.*)?$', block) + source = re.search(r"(?m)^\s*source\s*=\s*\{([^\n]*)\}\s*(?:#.*)?$", block) + if not name or not source: + continue + if re.search(r'\b(?:editable|virtual)\s*=\s*"\."(?:\s*[,}]|\s*$)', source.group(1) + "}"): + roots.append(name.group(1)) +if len(roots) != 1: + raise SystemExit(1) +sys.stdout.write(roots[0]) +PY +} + # Emit " " per detected component, deterministically -# ordered. A directory can yield at most one Python and one JS component. Three +# ordered. A directory can yield at most one Python and one JS component. Five # pseudo-ecosystems name a component this library will not install but must # still report: "js" is a JS component whose package manager could not be -# determined, "python" is a directory whose pyproject.toml declares a project -# while carrying neither a uv.lock nor a requirements.txt, and "unscanned" is a -# directory past FM_PROVISION_SCAN_DEPTH. The caller records each as a -# capability gap rather than guessing an installer the project does not use. -# The scan lines can be passed in by a caller that already scanned - -# fm_provision_worktree does, so its host-prerequisite decision lands before -# anything here reads a project-controlled file - and are scanned for otherwise. +# determined, "js-lock" is a JS lockfile without the package.json manifest its +# installer requires, "python" is a directory whose pyproject.toml declares a +# project while carrying neither a uv.lock nor a requirements.txt, +# "python-lock" is a standalone uv.lock whose root dependency set cannot be +# identified, and "unscanned" is a directory past FM_PROVISION_SCAN_DEPTH. +# The caller records each as a capability gap rather than guessing an installer +# the project does not use. +# The scan lines can be passed in by a caller that already scanned. +# fm_provision_worktree does so its host-prerequisite decision lands before +# anything here reads a project-controlled file, and other callers scan here. # Returns non-zero when the traversal itself failed, which is a refusal and not # the same thing as a traversal that succeeded and found nothing. fm_provision_detect() { # [...] - local wt=$1 dir rel scope line entry member workspace_info scanned declared declared_rc=0 + local wt=$1 dir rel scope line entry member workspace_info scanned declared root_name declared_rc=0 shift local -a lines=() js_managers=() candidates=() covered=() if [ "$#" -gt 0 ]; then @@ -476,10 +525,18 @@ fm_provision_detect() { # [...] continue fi dir=$(fm_provision_component_dir "$wt" "$rel") - # Python: uv-managed project wins over a bare requirements install, and a - # project declaring neither is named rather than passed over in silence. - if [ -f "$dir/uv.lock" ]; then + # Python: a uv project wins over a bare dependency list. A standalone + # uv.lock is distinct because uv sync cannot discover a project there and + # must install through cache-local synthetic metadata instead. + if [ -f "$dir/uv.lock" ] && [ -f "$dir/pyproject.toml" ]; then printf '%s %s\n' uv "$rel" + elif [ -f "$dir/uv.lock" ]; then + root_name=$(fm_provision_uv_lock_root_name "$dir") || root_name= + if [ -n "$root_name" ]; then + printf '%s %s\n' uv-lock "$rel" + else + printf '%s %s\n' python-lock "$rel" + fi elif [ -f "$dir/requirements.txt" ]; then printf '%s %s\n' pip "$rel" else @@ -504,6 +561,18 @@ fm_provision_detect() { # [...] fi [ ! -f "$dir/package-lock.json" ] || js_managers+=(npm) if [ "${#js_managers[@]}" -gt 0 ]; then + if [ ! -f "$dir/package.json" ]; then + if [ "${#js_managers[@]}" -eq 1 ]; then + case "${js_managers[0]}" in + yarn|bun) + printf '%s %s\n' "${js_managers[0]}" "$rel" + continue + ;; + esac + fi + printf 'js-lock %s\n' "$rel" + continue + fi declared=$(fm_provision_declared_js_manager "$dir") if [ -n "$declared" ]; then if fm_provision_list_has "$declared" "${js_managers[@]}"; then @@ -934,6 +1003,7 @@ fm_provision_manifests() { # local -a names=() case "$eco" in uv) names=(uv.lock uv.toml pyproject.toml .python-version) ;; + uv-lock) names=(uv.lock uv.toml .python-version) ;; pip) names=(requirements.txt requirements-dev.txt requirements-test.txt requirements_dev.txt requirements_test.txt @@ -1221,7 +1291,7 @@ fm_provision_environment_signature() { # local wt=$1 eco=$2 rel=$3 dir python state dir=$(fm_provision_component_dir "$wt" "$rel") case "$eco" in - uv|pip) + uv|uv-lock|pip) python="$dir/.venv/bin/python" [ -x "$python" ] || return 1 state=$(fm_provision_run_bounded "$FM_PROVISION_PROBE_TIMEOUT" "$dir" \ @@ -1310,7 +1380,7 @@ fm_provision_declared_packages_ready() { # local wt=$1 eco=$2 rel=$3 dir name rc=0 dir=$(fm_provision_component_dir "$wt" "$rel") case "$eco" in - uv) return 0 ;; + uv|uv-lock) return 0 ;; pip) ;; *) return 2 ;; esac @@ -1357,7 +1427,7 @@ fm_provision_probe() { # < dir=$(fm_provision_component_dir "$wt" "$rel") FM_PROVISION_PROBE_NOTE= case "$eco" in - uv|pip) + uv|uv-lock|pip) python="$dir/.venv/bin/python" [ -x "$python" ] || return 1 actual=$(fm_provision_run_bounded "$FM_PROVISION_PROBE_TIMEOUT" "$dir" \ @@ -1410,8 +1480,8 @@ fm_provision_probe() { # < # --- installs --------------------------------------------------------------- -fm_provision_install() { # - local wt=$1 eco=$2 rel=$3 log=$4 dir name +fm_provision_install() { # + local wt=$1 eco=$2 rel=$3 log=$4 cache=$5 dir name dir=$(fm_provision_component_dir "$wt" "$rel") case "$eco" in uv) @@ -1432,6 +1502,41 @@ fm_provision_install() { # env -u UV_NO_DEV -u UV_ONLY_DEV -u UV_NO_DEFAULT_GROUPS \ -u UV_NO_GROUP -u UV_ONLY_GROUP uv "${sync_args[@]}" || return $? ;; + uv-lock) + local root_name synthetic rc=0 cleanup_rc=0 + root_name=$(fm_provision_uv_lock_root_name "$dir") || return 1 + [ -n "$root_name" ] || return 1 + synthetic=$(mktemp -d "$cache/uv-lock.XXXXXX") || return 1 + fm_provision_run_bounded "$FM_PROVISION_PROBE_TIMEOUT" "$dir" \ + python3 - "$dir" "$synthetic" "$root_name" <<'PY' || rc=$? +import pathlib +import shutil +import sys + +source = pathlib.Path(sys.argv[1]) +target = pathlib.Path(sys.argv[2]) +root_name = sys.argv[3] +shutil.copyfile(source / "uv.lock", target / "uv.lock") +for name in ("uv.toml", ".python-version"): + path = source / name + if path.is_file(): + shutil.copyfile(path, target / name) +(target / "pyproject.toml").write_text( + '[project]\nname = "{}"\nversion = "0.0.0"\n'.format(root_name), + encoding="utf-8", +) +PY + if [ "$rc" -eq 0 ]; then + fm_provision_run_logged "$FM_PROVISION_INSTALL_TIMEOUT" "$synthetic" "$log" \ + env -u UV_NO_DEV -u UV_ONLY_DEV -u UV_NO_DEFAULT_GROUPS \ + -u UV_NO_GROUP -u UV_ONLY_GROUP -u UV_PROJECT \ + UV_PROJECT_ENVIRONMENT="$dir/.venv" \ + uv sync --frozen --no-install-project --no-install-workspace || rc=$? + fi + rm -rf "$synthetic" || cleanup_rc=$? + [ "$cleanup_rc" -eq 0 ] || return "$cleanup_rc" + [ "$rc" -eq 0 ] || return "$rc" + ;; pip) local -a args=(pip install --python .venv/bin/python) fm_provision_run_logged "$FM_PROVISION_INSTALL_TIMEOUT" "$dir" "$log" \ @@ -1460,7 +1565,7 @@ fm_provision_install() { # fm_provision_artifact_path() { # local eco=$1 rel=$2 name case "$eco" in - uv|pip) name=.venv ;; + uv|uv-lock|pip) name=.venv ;; npm|pnpm) name=node_modules ;; *) return 1 ;; esac @@ -1792,15 +1897,23 @@ fm_provision_worktree() { # [] eco=${line%% *} rel=${line#* } case "$eco" in - uv|pip|npm|pnpm) ;; + uv|uv-lock|pip|npm|pnpm) ;; js) states[index]=$(fm_provision_gap "$log" js "$rel" ambiguous-manager \ "its package manager is not determined by package.json's packageManager field and it carries no single lockfile that names one") ;; + js-lock) + states[index]=$(fm_provision_gap "$log" js-lock "$rel" no-package-manifest \ + "it carries a JavaScript lockfile but no package.json, and npm and pnpm both require that manifest before they can install the locked dependencies") + ;; python) states[index]=$(fm_provision_gap "$log" python "$rel" no-python-lockfile \ "it declares a pyproject.toml but neither a uv.lock nor a requirements.txt, and this firstmate installs Python only from a committed declaration") ;; + python-lock) + states[index]=$(fm_provision_gap "$log" python-lock "$rel" standalone-uv-lock-unresolved \ + "it carries a uv.lock without project metadata, but the lock does not identify one root dependency set that can be installed without treating the directory as a project") + ;; unscanned) states[index]=$(fm_provision_gap "$log" "$eco" "$rel" below-scan-depth \ "its manifest lies deeper than the FM_PROVISION_SCAN_DEPTH limit of $FM_PROVISION_SCAN_DEPTH, so it was never classified or installed") @@ -1978,13 +2091,13 @@ fm_provision_worktree() { # [] fi case "$eco" in - uv|pip) installer=$(fm_provision_tool_version uv --version) || installer= ;; + uv|uv-lock|pip) installer=$(fm_provision_tool_version uv --version) || installer= ;; npm) installer=$(fm_provision_tool_version npm --version) || installer= ;; pnpm) installer=$(fm_provision_tool_version pnpm --version) || installer= ;; esac if [ -z "$installer" ]; then case "$eco" in - uv|pip) + uv|uv-lock|pip) states[index]=$(fm_provision_gap "$log" "$eco" "$rel" missing-installer \ "firstmate provisions Python through uv, never pip or venv, and uv is not installed") ;; @@ -2043,7 +2156,7 @@ fm_provision_worktree() { # [] if [ "$state" = installed ]; then echo "fm-spawn: provisioning $eco dependencies in $rel" >&2 rc=0 - fm_provision_install "$wt" "$eco" "$rel" "$log" || rc=$? + fm_provision_install "$wt" "$eco" "$rel" "$log" "$cache" || rc=$? if [ "$rc" -ne 0 ]; then if [ "$rc" -eq 124 ]; then fm_provision_fail "the $eco install in $rel exceeded its ${FM_PROVISION_INSTALL_TIMEOUT}s bound" @@ -2053,7 +2166,7 @@ fm_provision_worktree() { # [] return 1 fi case "$eco" in - uv|pip) + uv|uv-lock|pip) runtime=$(fm_provision_run_bounded "$FM_PROVISION_PROBE_TIMEOUT" "$dir" \ "$dir/.venv/bin/python" -c 'import sys; sys.stdout.write("%d.%d.%d" % sys.version_info[:3])' 2>/dev/null) || runtime= if [ -z "$runtime" ]; then diff --git a/tests/fm-spawn-provision.test.sh b/tests/fm-spawn-provision.test.sh index 36c4753622a..a0df22a1be8 100755 --- a/tests/fm-spawn-provision.test.sh +++ b/tests/fm-spawn-provision.test.sh @@ -52,20 +52,25 @@ PY ;; sync) [ "${FM_TEST_UV_FAIL:-0}" != 1 ] || exit 3 + [ -f pyproject.toml ] || { + printf 'error: No `pyproject.toml` found in current directory or any parent directory\n' >&2 + exit 2 + } if [ "${FM_TEST_REQUIRE_UV_DEFAULT_GROUPS:-0}" = 1 ]; then [ -z "${UV_NO_DEV:-}${UV_ONLY_DEV:-}${UV_NO_DEFAULT_GROUPS:-}${UV_NO_GROUP:-}${UV_ONLY_GROUP:-}" ] \ || exit 9 fi - mkdir -p .venv/bin .venv/lib/python3.11/site-packages - printf 'fake uv project\n' > .venv/lib/python3.11/site-packages/fake_uv_project.pth - cat > .venv/bin/python <<'PY' + venv=${UV_PROJECT_ENVIRONMENT:-.venv} + mkdir -p "$venv/bin" "$venv/lib/python3.11/site-packages" + printf 'fake uv project\n' > "$venv/lib/python3.11/site-packages/fake_uv_project.pth" + cat > "$venv/bin/python" <<'PY' #!/usr/bin/env bash case "${2:-}" in *sys.version_info*) printf '%s' "${FM_TEST_PYTHON_VERSION:-3.11.9}" ;; *) exec python3 "$@" ;; esac PY - chmod +x .venv/bin/python + chmod +x "$venv/bin/python" exit 0 ;; pip) @@ -280,6 +285,162 @@ test_worktree_declaring_nothing_is_a_clean_noop() { pass "a worktree declaring no recognized manifest provisions nothing and succeeds" } +# --- library: dependency lists ---------------------------------------------- + +test_a_bare_requirements_list_installs_dependencies_not_a_project() { + local case_dir out fakebin detected + case_dir=$(new_case bare-requirements none) + fakebin=$(case_fakebin "$case_dir") + mkdir -p "$case_dir/wt/dependency-list" + printf 'six==1.16.0\n' > "$case_dir/wt/dependency-list/requirements.txt" + + detected=$(PATH="$fakebin:/usr/bin:/bin" bash -c ' + set -u + . "$1" + fm_provision_detect "$2" + ' _ "$LIB" "$case_dir/wt") + [ "$detected" = 'pip dependency-list' ] \ + || fail "a bare requirements.txt was not classified as a dependency list: $detected" + + out=$(run_provision "$case_dir" "$case_dir/wt" "$fakebin") + assert_contains "$out" 'rc=0 summary=pip:dependency-list=installed' \ + "a bare requirements.txt did not install successfully: $out" + assert_grep 'uv venv --clear .venv' "$case_dir/install.log" \ + "a bare requirements.txt did not create an environment" + assert_grep 'uv pip install --python .venv/bin/python -r requirements.txt' "$case_dir/install.log" \ + "a bare requirements.txt was not installed as dependencies" + assert_no_grep 'uv sync' "$case_dir/install.log" \ + "a bare requirements.txt was treated as a runnable uv project" + [ ! -e "$case_dir/wt/dependency-list/pyproject.toml" ] \ + || fail "provisioning wrote project metadata beside a bare requirements.txt" + [ ! -e "$case_dir/wt/dependency-list/setup.py" ] \ + || fail "provisioning wrote package metadata beside a bare requirements.txt" + pass "a bare requirements.txt installs only its dependencies" +} + +test_a_standalone_uv_lock_installs_dependencies_not_a_project() { + local case_dir out fakebin detected + case_dir=$(new_case standalone-uv-lock none) + fakebin=$(case_fakebin "$case_dir") + mkdir -p "$case_dir/wt/dependency-list" + cat > "$case_dir/wt/dependency-list/uv.lock" <<'LOCK' +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "dependency-list" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "six" }, +] + +[package.metadata] +requires-dist = [{ name = "six", specifier = "==1.16.0" }] + +[[package]] +name = "six" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +LOCK + + detected=$(PATH="$fakebin:/usr/bin:/bin" bash -c ' + set -u + . "$1" + fm_provision_detect "$2" + ' _ "$LIB" "$case_dir/wt") + [ "$detected" = 'uv-lock dependency-list' ] \ + || fail "a standalone uv.lock was not classified as a dependency list: $detected" + + out=$(run_provision "$case_dir" "$case_dir/wt" "$fakebin") + assert_contains "$out" 'rc=0 summary=uv-lock:dependency-list=installed' \ + "a standalone uv.lock did not install successfully: $out" + assert_grep 'uv sync --frozen --no-install-project --no-install-workspace' "$case_dir/install.log" \ + "a standalone uv.lock did not exclude the synthetic project from installation" + [ -x "$case_dir/wt/dependency-list/.venv/bin/python" ] \ + || fail "a standalone uv.lock did not install into the component environment" + [ ! -e "$case_dir/wt/dependency-list/pyproject.toml" ] \ + || fail "provisioning wrote synthetic project metadata into the component" + [ ! -e "$case_dir/wt/dependency-list/setup.py" ] \ + || fail "provisioning treated a standalone uv.lock as a package" + if find "$case_dir/cache" -type f -name pyproject.toml -print -quit | grep -q .; then + fail "provisioning left synthetic project metadata in the cache" + fi + + : > "$case_dir/install.log" + out=$(run_provision "$case_dir" "$case_dir/wt" "$fakebin") + assert_contains "$out" 'rc=0 summary=uv-lock:dependency-list=cached' \ + "an unchanged standalone uv.lock did not reuse its installed dependencies: $out" + assert_no_grep 'uv sync' "$case_dir/install.log" \ + "an unchanged standalone uv.lock repeated its install" + pass "a standalone uv.lock installs only its locked dependencies" +} + +test_an_unresolved_standalone_uv_lock_is_a_gap_not_a_failure() { + local case_dir out fakebin + case_dir=$(new_case unresolved-uv-lock none) + fakebin=$(case_fakebin "$case_dir") + mkdir -p "$case_dir/wt/dependency-list" + printf 'version = 1\nrevision = 3\nrequires-python = ">=3.11"\n' \ + > "$case_dir/wt/dependency-list/uv.lock" + + out=$(run_provision "$case_dir" "$case_dir/wt" "$fakebin") + assert_contains "$out" 'rc=0 summary=python-lock:dependency-list=skipped:standalone-uv-lock-unresolved' \ + "an unresolved standalone uv.lock refused the worktree instead of recording a gap: $out" + assert_no_grep 'uv sync' "$case_dir/install.log" \ + "an unresolved standalone uv.lock reached a project installer" + pass "an unresolved standalone uv.lock records a capability gap without refusing" +} + +test_an_npm_lock_without_a_package_manifest_is_a_gap_not_a_failure() { + local case_dir out fakebin detected + case_dir=$(new_case npm-lock-only none) + fakebin=$(case_fakebin "$case_dir") + mkdir -p "$case_dir/wt/dependency-list" + printf '{"name":"dependency-list","lockfileVersion":3,"packages":{"":{}}}\n' \ + > "$case_dir/wt/dependency-list/package-lock.json" + + detected=$(PATH="$fakebin:/usr/bin:/bin" bash -c ' + set -u + . "$1" + fm_provision_detect "$2" + ' _ "$LIB" "$case_dir/wt") + [ "$detected" = 'js-lock dependency-list' ] \ + || fail "a package-lock.json without package.json was misclassified: $detected" + + out=$(run_provision "$case_dir" "$case_dir/wt" "$fakebin") + assert_contains "$out" 'rc=0 summary=js-lock:dependency-list=skipped:no-package-manifest' \ + "a package-lock.json without package.json refused the worktree: $out" + assert_no_grep 'npm ci' "$case_dir/install.log" \ + "a package-lock.json without package.json reached npm ci" + pass "an npm lock without its required manifest records a capability gap" +} + +test_a_pnpm_lock_without_a_package_manifest_is_a_gap_not_a_failure() { + local case_dir out fakebin detected + case_dir=$(new_case pnpm-lock-only none) + fakebin=$(case_fakebin "$case_dir") + mkdir -p "$case_dir/wt/dependency-list" + printf 'lockfileVersion: 9\nimporters:\n .: {}\n' \ + > "$case_dir/wt/dependency-list/pnpm-lock.yaml" + + detected=$(PATH="$fakebin:/usr/bin:/bin" bash -c ' + set -u + . "$1" + fm_provision_detect "$2" + ' _ "$LIB" "$case_dir/wt") + [ "$detected" = 'js-lock dependency-list' ] \ + || fail "a pnpm-lock.yaml without package.json was misclassified: $detected" + + out=$(run_provision "$case_dir" "$case_dir/wt" "$fakebin") + assert_contains "$out" 'rc=0 summary=js-lock:dependency-list=skipped:no-package-manifest' \ + "a pnpm-lock.yaml without package.json refused the worktree: $out" + assert_no_grep 'pnpm install' "$case_dir/install.log" \ + "a pnpm-lock.yaml without package.json reached pnpm install" + pass "a pnpm lock without its required manifest records a capability gap" +} + # --- library: install, then cache hit --------------------------------------- test_first_spawn_installs_and_second_reuses_the_cache() { @@ -1947,6 +2108,11 @@ test_spawn_into_an_undeclared_project_is_unchanged() { } test_worktree_declaring_nothing_is_a_clean_noop +test_a_bare_requirements_list_installs_dependencies_not_a_project +test_a_standalone_uv_lock_installs_dependencies_not_a_project +test_an_unresolved_standalone_uv_lock_is_a_gap_not_a_failure +test_an_npm_lock_without_a_package_manifest_is_a_gap_not_a_failure +test_a_pnpm_lock_without_a_package_manifest_is_a_gap_not_a_failure test_first_spawn_installs_and_second_reuses_the_cache test_a_changed_manifest_invalidates_the_cache test_changed_installer_configuration_invalidates_the_cache From e1d19df1f033a5102cfc458a04cc108bb4b36f1b Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Mon, 17 Aug 2026 12:27:30 -0400 Subject: [PATCH 2/9] no-mistakes(review): Prefer requirements over standalone uv locks --- bin/fm-provision-lib.sh | 11 ++++++----- tests/fm-spawn-provision.test.sh | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/bin/fm-provision-lib.sh b/bin/fm-provision-lib.sh index e0f85a94f00..5369b47f17f 100755 --- a/bin/fm-provision-lib.sh +++ b/bin/fm-provision-lib.sh @@ -525,11 +525,14 @@ fm_provision_detect() { # [...] continue fi dir=$(fm_provision_component_dir "$wt" "$rel") - # Python: a uv project wins over a bare dependency list. A standalone - # uv.lock is distinct because uv sync cannot discover a project there and - # must install through cache-local synthetic metadata instead. + # Python: a uv project wins over a bare dependency list. Without project + # metadata, requirements.txt is the directly supported dependency-list + # declaration even when a standalone uv.lock is also present. A standalone + # uv.lock otherwise installs through cache-local synthetic metadata. if [ -f "$dir/uv.lock" ] && [ -f "$dir/pyproject.toml" ]; then printf '%s %s\n' uv "$rel" + elif [ -f "$dir/requirements.txt" ]; then + printf '%s %s\n' pip "$rel" elif [ -f "$dir/uv.lock" ]; then root_name=$(fm_provision_uv_lock_root_name "$dir") || root_name= if [ -n "$root_name" ]; then @@ -537,8 +540,6 @@ fm_provision_detect() { # [...] else printf '%s %s\n' python-lock "$rel" fi - elif [ -f "$dir/requirements.txt" ]; then - printf '%s %s\n' pip "$rel" else declared_rc=0 fm_provision_python_project_declared "$dir" || declared_rc=$? diff --git a/tests/fm-spawn-provision.test.sh b/tests/fm-spawn-provision.test.sh index a0df22a1be8..042cfdada91 100755 --- a/tests/fm-spawn-provision.test.sh +++ b/tests/fm-spawn-provision.test.sh @@ -318,6 +318,33 @@ test_a_bare_requirements_list_installs_dependencies_not_a_project() { pass "a bare requirements.txt installs only its dependencies" } +test_bare_requirements_take_precedence_over_a_standalone_uv_lock() { + local case_dir out fakebin detected + case_dir=$(new_case requirements-and-uv-lock none) + fakebin=$(case_fakebin "$case_dir") + mkdir -p "$case_dir/wt/dependency-list" + printf 'six==1.16.0\n' > "$case_dir/wt/dependency-list/requirements.txt" + printf 'version = 1\nrevision = 3\nrequires-python = ">=3.11"\n' \ + > "$case_dir/wt/dependency-list/uv.lock" + + detected=$(PATH="$fakebin:/usr/bin:/bin" bash -c ' + set -u + . "$1" + fm_provision_detect "$2" + ' _ "$LIB" "$case_dir/wt") + [ "$detected" = 'pip dependency-list' ] \ + || fail "requirements.txt did not take precedence over a standalone uv.lock: $detected" + + out=$(run_provision "$case_dir" "$case_dir/wt" "$fakebin") + assert_contains "$out" 'rc=0 summary=pip:dependency-list=installed' \ + "requirements.txt plus a standalone uv.lock did not install successfully: $out" + assert_grep 'uv pip install --python .venv/bin/python -r requirements.txt' "$case_dir/install.log" \ + "requirements.txt plus a standalone uv.lock did not use the dependency installer" + assert_no_grep 'uv sync' "$case_dir/install.log" \ + "requirements.txt plus a standalone uv.lock reached the project-style installer" + pass "bare requirements take precedence over a standalone uv.lock" +} + test_a_standalone_uv_lock_installs_dependencies_not_a_project() { local case_dir out fakebin detected case_dir=$(new_case standalone-uv-lock none) @@ -2109,6 +2136,7 @@ test_spawn_into_an_undeclared_project_is_unchanged() { test_worktree_declaring_nothing_is_a_clean_noop test_a_bare_requirements_list_installs_dependencies_not_a_project +test_bare_requirements_take_precedence_over_a_standalone_uv_lock test_a_standalone_uv_lock_installs_dependencies_not_a_project test_an_unresolved_standalone_uv_lock_is_a_gap_not_a_failure test_an_npm_lock_without_a_package_manifest_is_a_gap_not_a_failure From c6a387055c99d59ebb15ce958a987fbf3fb0e1d9 Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Mon, 17 Aug 2026 12:30:03 -0400 Subject: [PATCH 3/9] no-mistakes(review): Reject path-dependent standalone uv locks --- bin/fm-provision-lib.sh | 12 ++++++---- tests/fm-spawn-provision.test.sh | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/bin/fm-provision-lib.sh b/bin/fm-provision-lib.sh index 5369b47f17f..61d3c96ed7c 100755 --- a/bin/fm-provision-lib.sh +++ b/bin/fm-provision-lib.sh @@ -419,9 +419,10 @@ fm_provision_scan() { # # A standalone uv.lock has no project file for uv to discover, but the lock # still names the root whose dependency edges select the environment. Return # that one root package name only when uv's generated lock shape identifies it -# unambiguously through a virtual or editable source at ".". An unreadable, -# malformed, or rootless lock returns non-zero so detection can record a gap -# instead of attempting a project sync that must fail. +# unambiguously through a virtual or editable source at "." and every other +# package source is independent of the original project path. An unreadable, +# malformed, path-dependent, or rootless lock returns non-zero so detection can +# record a gap instead of attempting an incomplete project sync. fm_provision_uv_lock_root_name() { # local dir=$1 fm_provision_run_bounded "$FM_PROVISION_PROBE_TIMEOUT" "$dir" \ @@ -441,8 +442,11 @@ for block in blocks: source = re.search(r"(?m)^\s*source\s*=\s*\{([^\n]*)\}\s*(?:#.*)?$", block) if not name or not source: continue - if re.search(r'\b(?:editable|virtual)\s*=\s*"\."(?:\s*[,}]|\s*$)', source.group(1) + "}"): + source_text = source.group(1) + if re.search(r'\b(?:editable|virtual)\s*=\s*"\."(?:\s*[,}]|\s*$)', source_text + "}"): roots.append(name.group(1)) + elif re.search(r'\b(?:editable|virtual|directory|path|workspace)\s*=', source_text): + raise SystemExit(1) if len(roots) != 1: raise SystemExit(1) sys.stdout.write(roots[0]) diff --git a/tests/fm-spawn-provision.test.sh b/tests/fm-spawn-provision.test.sh index 042cfdada91..9a4f9e06283 100755 --- a/tests/fm-spawn-provision.test.sh +++ b/tests/fm-spawn-provision.test.sh @@ -420,6 +420,46 @@ test_an_unresolved_standalone_uv_lock_is_a_gap_not_a_failure() { pass "an unresolved standalone uv.lock records a capability gap without refusing" } +test_a_path_dependent_standalone_uv_lock_is_a_gap() { + local case_dir out fakebin detected + case_dir=$(new_case path-dependent-uv-lock none) + fakebin=$(case_fakebin "$case_dir") + mkdir -p "$case_dir/wt/dependency-list" + cat > "$case_dir/wt/dependency-list/uv.lock" <<'LOCK' +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "dependency-list" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "local-dependency" }, +] + +[[package]] +name = "local-dependency" +version = "0.0.0" +source = { editable = "../local-dependency" } +LOCK + + detected=$(PATH="$fakebin:/usr/bin:/bin" bash -c ' + set -u + . "$1" + fm_provision_detect "$2" + ' _ "$LIB" "$case_dir/wt") + [ "$detected" = 'python-lock dependency-list' ] \ + || fail "a path-dependent standalone uv.lock was classified as installable: $detected" + + out=$(run_provision "$case_dir" "$case_dir/wt" "$fakebin") + assert_contains "$out" 'rc=0 summary=python-lock:dependency-list=skipped:standalone-uv-lock-unresolved' \ + "a path-dependent standalone uv.lock did not record a capability gap: $out" + assert_no_grep 'uv sync' "$case_dir/install.log" \ + "a path-dependent standalone uv.lock reached the copied-lock installer" + pass "a path-dependent standalone uv.lock records a capability gap" +} + test_an_npm_lock_without_a_package_manifest_is_a_gap_not_a_failure() { local case_dir out fakebin detected case_dir=$(new_case npm-lock-only none) @@ -2139,6 +2179,7 @@ test_a_bare_requirements_list_installs_dependencies_not_a_project test_bare_requirements_take_precedence_over_a_standalone_uv_lock test_a_standalone_uv_lock_installs_dependencies_not_a_project test_an_unresolved_standalone_uv_lock_is_a_gap_not_a_failure +test_a_path_dependent_standalone_uv_lock_is_a_gap test_an_npm_lock_without_a_package_manifest_is_a_gap_not_a_failure test_a_pnpm_lock_without_a_package_manifest_is_a_gap_not_a_failure test_first_spawn_installs_and_second_reuses_the_cache From df0491f366a26dcae34b852722fd61ae951e956d Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Mon, 17 Aug 2026 14:55:39 -0400 Subject: [PATCH 4/9] no-mistakes(test): Isolate watcher guard configuration fixture --- tests/fm-watcher-lock.test.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/fm-watcher-lock.test.sh b/tests/fm-watcher-lock.test.sh index 714d0647109..98defe49bdc 100755 --- a/tests/fm-watcher-lock.test.sh +++ b/tests/fm-watcher-lock.test.sh @@ -140,7 +140,8 @@ test_guard_warnings() { mkdir -p "$dir/config" printf 'project=x\n' > "$state/task.meta" : > "$dir/config/x-mode.env" - FM_ROOT_OVERRIDE="$dir" FM_STATE_OVERRIDE="$state" FM_GUARD_GRACE=1 "$ROOT/bin/fm-guard.sh" 2> "$err" >/dev/null || fail "guard failed" + FM_ROOT_OVERRIDE="$dir" FM_STATE_OVERRIDE="$state" FM_CONFIG_OVERRIDE="$dir/config" \ + FM_GUARD_GRACE=1 "$ROOT/bin/fm-guard.sh" 2> "$err" >/dev/null || fail "guard failed" grep -F "source '$dir/config/x-mode.env' first" "$err" >/dev/null || fail "guard repair line did not source the X-mode cadence config" # (2) fresh watcher, empty queue -> silence. From 3950a1ba9f7c7a5f79b0ebddeeb2ef8fe89e5b8d Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Mon, 17 Aug 2026 17:24:32 -0400 Subject: [PATCH 5/9] no-mistakes(test): Isolate watcher fixtures from live configuration --- tests/fm-watch-triage.test.sh | 2 +- tests/wake-helpers.sh | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index de4ed504e07..ad6e1ef185a 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -1141,7 +1141,7 @@ test_nonterminal_stale_paused_absorbed_then_resurfaced() { : > "$out" printf 'idle, holding for upstream (token 2)' > "$capture_file" PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ - FM_HOME="$dir" FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_HOME="$dir" FM_CONFIG_OVERRIDE="$dir/config" FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" FM_POLL=1 FM_SIGNAL_GRACE=1 \ FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & pid=$! wait_for_exit "$pid" 40 || fail "watcher did not re-surface a declared pause past the threshold" diff --git a/tests/wake-helpers.sh b/tests/wake-helpers.sh index 9f29c61a3cf..1df0d7b87ce 100644 --- a/tests/wake-helpers.sh +++ b/tests/wake-helpers.sh @@ -30,6 +30,12 @@ SH chmod +x "$FM_ROOT_OVERRIDE/bin/fm-auto-reap.sh" fi +# Watcher fixtures must not source the active firstmate home's watcher.env. +# Keep their default config empty and isolated; config-specific cases override +# this path explicitly with the fixture they exercise. +fm_test_tmproot_into FM_CONFIG_OVERRIDE fm-wake-config +export FM_CONFIG_OVERRIDE + # Wedge-alarm notifier recorder (safety seam). The away-mode wedge alarm fires a # real OS-level desktop notification by default. Point its FM_WEDGE_ALARM_EXEC # seam at a recorder for every From 95c4d7d770288e42dc8bef2127afd7addf248e50 Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Mon, 17 Aug 2026 19:35:57 -0400 Subject: [PATCH 6/9] no-mistakes(test): Isolate test homes and reap Herdr event reader --- bin/backends/herdr.sh | 9 ++++++++- tests/fm-azure-runner.test.sh | 9 +++++++-- tests/fm-backend.test.sh | 3 +++ tests/fm-session-start.test.sh | 2 +- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 66c108708eb..50638bc0f1e 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -3535,7 +3535,14 @@ fm_backend_herdr_wait_transition() { # /dev/null || true return 2 fi - fm_backend_herdr_scrubbed_exec "${reader[@]}" "$sock" "$timeout" "${pane_ids[@]}" > "$fifo" 2>/dev/null & + ( + export LD_PRELOAD='' LD_LIBRARY_PATH='' LD_AUDIT='' LD_DEBUG='' + export DYLD_INSERT_LIBRARIES='' DYLD_LIBRARY_PATH='' DYLD_FRAMEWORK_PATH='' + export DYLD_FALLBACK_LIBRARY_PATH='' DYLD_FALLBACK_FRAMEWORK_PATH='' + export PERL5OPT='' PERL5LIB='' PERLLIB='' NODE_OPTIONS='' NODE_PATH='' + export PYTHONHOME='' PYTHONPATH='' RUBYOPT='' RUBYLIB='' BASH_ENV='' ENV='' GCONV_PATH='' + exec "${reader[@]}" "$sock" "$timeout" "${pane_ids[@]}" + ) > "$fifo" 2>/dev/null & reader_pid=$! if ! exec 9< "$fifo"; then kill "$reader_pid" 2>/dev/null || true diff --git a/tests/fm-azure-runner.test.sh b/tests/fm-azure-runner.test.sh index 7855c16e396..5f064d990b5 100755 --- a/tests/fm-azure-runner.test.sh +++ b/tests/fm-azure-runner.test.sh @@ -560,6 +560,7 @@ shared_allocator_bridge_unit() { python3 - "$HOST" <<'PY' || fail "shared allocator runner bridge failed" import importlib.util, json, pathlib, subprocess, tempfile, sys spec=importlib.util.spec_from_file_location("runner_shared",sys.argv[1]); m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m) +fixture_home=pathlib.Path(tempfile.mkdtemp()) env={"subscription":"sub","resource_group":"rg","prefix":"prefix","budget_limit":1500,"state_dir":pathlib.Path(tempfile.mkdtemp()),"azure_operation_count":0} limits={**m.RESOURCE_CLASSES["behavior-heavy"],"sku":"Standard_D4as_v7","sku_family":"StandardDasv7Family"} state={"schema":m.SCHEMA,"invocation":"azr-aaaaaaaaaaaa","resources":{},"request":{"fence":"sha256:"+"a"*64,"resource_class":"behavior-heavy","limits":limits}} @@ -568,10 +569,12 @@ calls=[] def completed(value): return subprocess.CompletedProcess(["python"],0,stdout=json.dumps(value),stderr="") def allocator_run(command,**kwargs): - assert kwargs["env"]["FM_HOME"]==str(m.ROOT) - assert kwargs["env"]["FM_AZURE_WORKER_STATE_DIR"]==str(m.ROOT/"state"/"azure-workers") + assert kwargs["env"]["FM_HOME"]==str(fixture_home) + assert kwargs["env"]["FM_AZURE_WORKER_STATE_DIR"]==str((fixture_home/"state"/"azure-workers").resolve()) calls.append(command) return completed({"reservation_id":"azr-aaaaaaaaaaaa","status":"reserved","reason":"","actual_usd":100.0,"forecast_usd":200.0,"admission_limit_usd":1500.0}) +old_home=m.os.environ.get("FM_HOME") +m.os.environ["FM_HOME"]=str(fixture_home) m.run=allocator_run cost={"max_increment":25.0} result=m.shared_capacity_reserve(env,state,cost) @@ -594,6 +597,8 @@ state["shared_capacity_reservation"]={"status":"reserved"} m.shared_capacity_release(env,state) assert "capacity-release" in calls[-1] and len(calls[-1][calls[-1].index("--cleanup-receipt")+1])==64 assert state["shared_capacity_reservation"]["status"]=="released" +if old_home is None:m.os.environ.pop("FM_HOME",None) +else:m.os.environ["FM_HOME"]=old_home PY pass "runner queues behind the shared allocator and requires actual/forecast evidence before compute" } diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 47efb141e9b..04c086d16ea 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -38,6 +38,9 @@ fm_git_identity fmtest fmtest@example.invalid . "$ROOT/bin/fm-backend.sh" fm_test_tmproot_into TMP_ROOT fm-backend-tests +FM_HOME="$TMP_ROOT/fm-home" +export FM_HOME +mkdir -p "$FM_HOME" # fm_backend_detect's cmux fallback (bundle id + process ancestry, # docs/cmux-backend.md "Runtime auto-detection") consults uname, lsappinfo, diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 51073827851..6b3a5d7301d 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -209,7 +209,7 @@ SH run_session_start() { local home=$1 root=$2 path=$3 env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT \ - FM_HOME="$home" FM_ROOT_OVERRIDE="$root" PATH="$path" \ + FM_HOME="$home" FM_ROOT_OVERRIDE="$root" FM_CONFIG_OVERRIDE="$home/config" PATH="$path" \ FM_BACKEND_HERDR_TEST_LAB=firstmate-herdr-test-lab-v1 \ "$SESSION_START" } From 00cc640bab265e09121c48176b6d8f0264a3abf8 Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Mon, 17 Aug 2026 21:44:57 -0400 Subject: [PATCH 7/9] no-mistakes(test): Isolate arm policy fixture home --- tests/fm-arm-pretool-check.test.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/fm-arm-pretool-check.test.sh b/tests/fm-arm-pretool-check.test.sh index fdd7bb2b5bc..78322e71d10 100755 --- a/tests/fm-arm-pretool-check.test.sh +++ b/tests/fm-arm-pretool-check.test.sh @@ -16,6 +16,10 @@ set -u CHECK="$ROOT/bin/fm-arm-pretool-check.sh" POLICY="$ROOT/bin/fm-arm-command-policy.mjs" +# The executable transport resolves approved absolute setup paths against the +# active firstmate home. Keep that input owned by this fixture, not the caller. +export FM_HOME="$ROOT" + # --- full cross-harness acceptance matrix ---------------------------------- MATRIX_IDS=() From a0eeff77af67ade6d4c45b11f8914444ab0ba8cb Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Tue, 18 Aug 2026 04:18:32 -0400 Subject: [PATCH 8/9] no-mistakes(test): Audit external Herdr churn while preserving attributed tripwire --- bin/fm-herdr-lab.sh | 30 ++++++++++++++++++++++++------ tests/fm-herdr-lab.test.sh | 29 ++++++++++++++++++++++------- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/bin/fm-herdr-lab.sh b/bin/fm-herdr-lab.sh index d83dd7e9b2d..c890f26590d 100755 --- a/bin/fm-herdr-lab.sh +++ b/bin/fm-herdr-lab.sh @@ -28,7 +28,8 @@ # loudly on a stopped or unreadable default. # Provision records the running default session, its workspace/tab/pane # topology, and its agent identities as a fleet-state tripwire. Teardown -# requires that record to be identical afterward. +# refuses default-session identity drift and topology drift attributable to the +# named lab, while auditing unrelated same-operator fleet churn. # FM_HERDR_LAB_PROVISION_TIMEOUT_SECONDS is a whole number from 1 through 600. # It defaults to 120 so a loaded fleet gets a fair but bounded startup window. set -u @@ -401,7 +402,7 @@ fm_herdr_lab_provision() { # } fm_herdr_lab_check_tripwire() { # - local name=$1 tripwire before after + local name=$1 tripwire before after verdict tripwire=$(fm_herdr_lab_tripwire_path "$name") [ -f "$tripwire" ] || { fm_herdr_lab_error "missing fleet-state tripwire for '$name'; refusing unverified teardown" @@ -409,12 +410,29 @@ fm_herdr_lab_check_tripwire() { # } before=$(cat "$tripwire") after=$(fm_herdr_lab_fleet_state "$name") || return 1 - [ "$before" = "$after" ] || { - fm_herdr_lab_error "FLEET-STATE TRIPWIRE FAILED: default session changed during lab work" - fm_herdr_lab_error "before: $before" - fm_herdr_lab_error "after: $after" + [ "$before" = "$after" ] && return 0 + verdict=$(jq -nrce --argjson before "$before" --argjson after "$after" --arg name "$name" ' + def topology: [.workspaces, .tabs, .panes, .agents]; + if $before.session != $after.session then "identity" + elif (($before | topology | tojson | contains($name)) or + ($after | topology | tojson | contains($name))) then "attributed" + else "external" + end + ' 2>/dev/null) || { + fm_herdr_lab_error "FLEET-STATE TRIPWIRE FAILED: cannot classify default-session drift" return 1 } + case "$verdict" in + external) + fm_herdr_lab_error "fleet-state audit: unrelated default-session topology changed during lab work; guarded teardown may continue" + ;; + *) + fm_herdr_lab_error "FLEET-STATE TRIPWIRE FAILED: default-session drift is attributable to the lab or changed its identity" + fm_herdr_lab_error "before: $before" + fm_herdr_lab_error "after: $after" + return 1 + ;; + esac } fm_herdr_lab_verify_tripwire() { # diff --git a/tests/fm-herdr-lab.test.sh b/tests/fm-herdr-lab.test.sh index dbf6eef6fea..48d6ad1c5ff 100755 --- a/tests/fm-herdr-lab.test.sh +++ b/tests/fm-herdr-lab.test.sh @@ -258,17 +258,31 @@ test_changed_default_trips_after_teardown() { pass "fm-herdr-lab: changed default fleet state is a hard failure" } -test_changed_default_fleet_members_trip_after_teardown() { - local name="fm-lab-tripwire-members-$$" status=0 +test_external_default_fleet_churn_is_audited() { + local name="fm-lab-tripwire-members-$$" output : > "$FAKE_LOG" run_with_fake fm_herdr_lab_provision "$name" || fail "fleet-member tripwire fixture provision failed" printf '%s\n' captain > "$FAKE_STATE/default-agents" + output=$(run_with_fake fm_herdr_lab_teardown "$name" 2>&1) \ + || fail "unrelated default fleet churn blocked guarded teardown" + printf '%s\n' "$output" | grep -F "fleet-state audit: unrelated default-session topology changed" >/dev/null \ + || fail "unrelated default fleet churn was not audited" + assert_absent "$TRIPWIRES/$name.fleet-state.json" "audited external churn retained owned lab state" + printf '%s\n' captain crewmate-1 crewmate-2 > "$FAKE_STATE/default-agents" + pass "fm-herdr-lab: unrelated same-operator fleet churn is audited without blocking teardown" +} + +test_lab_attributed_default_fleet_drift_trips() { + local name="fm-lab-attributed-drift-$$" status=0 + : > "$FAKE_LOG" + run_with_fake fm_herdr_lab_provision "$name" || fail "attributed-drift fixture provision failed" + printf '%s\n' captain crewmate-1 crewmate-2 "$name" > "$FAKE_STATE/default-agents" run_with_fake fm_herdr_lab_teardown "$name" >/dev/null 2>&1 || status=$? - expect_code 1 "$status" "lost default fleet members must fail teardown" - assert_present "$TRIPWIRES/$name.fleet-state.json" "fleet-member tripwire failure should retain evidence" + expect_code 1 "$status" "lab-attributed default fleet drift must refuse teardown" + assert_present "$TRIPWIRES/$name.fleet-state.json" "attributed fleet drift discarded ownership evidence" printf '%s\n' captain crewmate-1 crewmate-2 > "$FAKE_STATE/default-agents" - rm -f "$TRIPWIRES/$name.fleet-state.json" - pass "fm-herdr-lab: default pane and agent deaths trip the fleet-state guard" + run_with_fake fm_herdr_lab_teardown "$name" || fail "attributed-drift cleanup failed after default fleet repair" + pass "fm-herdr-lab: lab-attributed default fleet drift retains teardown refusal" } test_default_runtime_activity_does_not_trip() { @@ -389,7 +403,8 @@ test_provision_run_and_guarded_teardown test_missing_tripwire_blocks_destruction test_agent_argv_inserts_session_before_separator test_changed_default_trips_after_teardown -test_changed_default_fleet_members_trip_after_teardown +test_external_default_fleet_churn_is_audited +test_lab_attributed_default_fleet_drift_trips test_default_runtime_activity_does_not_trip test_stopped_default_refuses_provision test_malformed_default_running_refuses_provision From a1e8230432496da49b9eb11fba0cc97dde30ffa9 Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Tue, 18 Aug 2026 06:27:47 -0400 Subject: [PATCH 9/9] no-mistakes(document): Document dependency-only provisioning behavior --- docs/configuration.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 37d32302986..4b6a778cc93 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -324,7 +324,9 @@ A lane launched into that state cannot run the project's own tests, formatters, This section records the operator-facing behavior only. Detection is driven by what the worktree declares, never by a project name. -A directory holding `uv.lock` provisions with `uv sync --frozen`; one holding `requirements.txt` gets a `uv venv` virtual environment plus its `requirements.txt` and any conventional `requirements-dev.txt` / `requirements-test.txt` companions; `package-lock.json` runs `npm ci`; `pnpm-lock.yaml` runs `pnpm install --frozen-lockfile`. +A `requirements.txt` without Python project metadata is treated as a dependency list: provisioning creates a `uv venv` environment and installs the listed requirements without installing the directory as a package. +A standalone `uv.lock` is handled the same way only when its root dependency set is unambiguous; otherwise it records a capability gap and launches unprovisioned. +JavaScript lockfiles require a `package.json`; a lockfile-only directory records a capability gap instead of being passed to a package manager. Python always goes through uv, never pip or venv directly. A directory whose `pyproject.toml` declares a project - a `[project]`, `[build-system]`, or `[tool.poetry]` table - with neither of those two Python manifests is enumerated and reported as a capability gap rather than installed from a guess; a uv workspace member is excused, because its root's `uv sync --all-packages` already installs it, and a `pyproject.toml` holding only tool configuration (`[tool.ruff]`, `[tool.black]`, `[tool.pytest.ini_options]`) declares nothing to provision and is a clean no-op. A pip component's fingerprint covers the requirements files it reaches through `-r` / `-c` includes as well as the ones named directly, so editing an included file is a cache miss rather than a false hit.