diff --git a/.github/workflows/batch-check.yml b/.github/workflows/batch-check.yml index 813dc233..f481558b 100644 --- a/.github/workflows/batch-check.yml +++ b/.github/workflows/batch-check.yml @@ -681,6 +681,17 @@ jobs: run: | & tests\selfapps_pvw_idempotent.ps1 + # CLAUDE.md former Active Backlog item 10: PVW_PYTHON_EXE and PVW_WORKSPACE had ZERO test + # coverage of any kind, unlike the other three PVW_* super-user overrides. Non-gating for + # its first landing, matching this repo's established graduation pattern for a new + # PowerShell scenario not yet proven stable across several real runs. + - name: "Self-test: PVW_PYTHON_EXE / PVW_WORKSPACE super-user overrides (uv lane only)" + if: ${{ matrix.mode == 'uv' }} + continue-on-error: true + shell: pwsh + run: | + & tests\selfapps_pvw_overrides.ps1 + - name: "Self-test: pre-build collect-submodules double-gate (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} shell: pwsh diff --git a/CLAUDE.md b/CLAUDE.md index dc7c49cc..9ac1ce48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -492,107 +492,6 @@ Once an item is fully resolved it is removed from here entirely and archived (ke original number) in `docs/agent-closed-backlog.md`, which is why the numbering below does not start at 1 and has gaps. -- **10. Two of the five `PVW_*` super-user override variables (`PVW_PYTHON_EXE`, `PVW_WORKSPACE`) - have ZERO test coverage of any kind, and ALL FIVE have zero coverage of their invalid-value - behavior -- found 2026-07-29 while documenting them for `docs/demo-bootstrapper-output.md`'s - Part V.** Confirmed via a repo-wide search across every `tests/*.ps1` file and every lane of a - recent clean CI run (`30328748330`): `PVW_PYTHON_EXE` and `PVW_WORKSPACE` have zero references - anywhere -- not even a valid-value smoke test. `PVW_UV_EXE` (`tests/selfapps_contract_uv.ps1`) - and `PVW_TARGET_PY` (`tests/selfapps_pipgap.ps1`) each have real, valid-value end-to-end CI - coverage, but that coverage is incidental to each test's own actual purpose (avoiding a - redundant uv re-download; pinning a Python version for an unrelated opencv-python wheel test), - not a dedicated test of the override mechanism itself. `PVW_CONDA_EXE` is the one exception with - dedicated, purpose-built coverage (`self.corrupt.conda.override_exit`, CLAUDE.md's own earlier - Active Backlog item 12 history). - - **No test anywhere exercises an INVALID value for any of the five** -- e.g. `PVW_PYTHON_EXE` - pointing at a nonexistent file, `PVW_UV_EXE` pointing at something that isn't actually uv, - `PVW_TARGET_PY` set to a malformed version spec, `PVW_WORKSPACE` pointing at an unwritable or - already-occupied-by-something-else path. Static tracing (done for the demo doc, see Part V) - shows each case is plausibly absorbed gracefully by pre-existing fallback/cascade machinery - (the REQ-009 provider cascade, the `:uv_venv_fail` chain, the interpreter smoke-test WARN), - never an uncontrolled crash -- but this is reasoned from source, not empirically confirmed by - any real run. - - **Not fixed in this pass** -- documentation-only task, and building 5+ new dedicated test - scenarios (a `PVW_PYTHON_EXE`-focused test, a `PVW_WORKSPACE`-focused test, plus invalid-value - variants for all five) is real, multi-scenario engineering work, not a quick addition. Suggested - shape for a future pass: one new `tests/selfapps_pvw_overrides.ps1`-style file (mirroring the - existing `tests/selfapps_ux_hardening.ps1`/`...contract_uv.ps1` pattern) covering the two - currently-zero-coverage variables' valid-value paths first (cheapest, highest-value gap), then - a smaller number of representative invalid-value scenarios (not all 5x2 combinations -- the - failure-absorption mechanism is shared/generic across most of them per the static trace above, - so 2-3 representative invalid-value cases would likely cover the real risk without a - combinatorial test matrix). - -- **12. `:embed_dl_retry`'s genuine mid-download-failure-then-retry-once path (REQ-009 Tier 5) has - no CI test hook at all -- found 2026-07-29 while documenting the embed-tier download for - `docs/demo-bootstrapper-output.md`'s Part VI, Scenario 20.** Confirmed via `run_setup.bat` - source and a repo-wide grep: only two test hooks exist for this tier -- - `HP_TEST_FORCE_EMBED_FAIL` (immediate decline, no download attempted at all) and - `HP_TEST_FORCE_EMBED_REAL` (a full, real, successful download end-to-end). Neither exercises - the retry branch itself: `:embed_dl_retry`'s own `[WARN] embed fallback: download failed; - retrying once.` line (both the curl and PowerShell download attempts failing on the FIRST try, - then succeeding on the second) and the sibling checksum/extraction-failure retry branch - (`[WARN] embed fallback: checksum verification or extraction failed; retrying download once.`) - are both reachable in principle but have never been observed firing in any real CI run - examined for this documentation pass -- the demo doc's own Scenario 20 correctly labels the - retry-branch text `[Extrapolated Branch]`, cited from source, for exactly this reason. **Not - fixed in this pass** -- documentation-only task; a dedicated test would need a new hook (e.g. - `HP_TEST_FORCE_EMBED_DL_FAIL_ONCE=1`, mirroring the existing `HP_TEST_FORCE_CONDA_CREATE_ - NETWORK_FAIL`-style one-shot-then-succeed pattern already used for REQ-022's conda-create - retry) that fails the first curl+PowerShell attempt deterministically without touching the - real network, then lets the second attempt through for real. Suggested as a small, well-scoped - future addition to `tests/selfapps_ux_hardening.ps1` alongside the existing - `self.embed.fallback.decline`/`.real` scenarios. - -- **13. `self.warn.longpath`'s own real CI run examined for this documentation pass shows an - INCONCLUSIVE result (`ranBootstrap:false`), yet the test still reports an overall pass -- found - 2026-07-29 while documenting the path-length pre-flight guard for - `docs/demo-bootstrapper-output.md`'s Part VI, Scenario 24.** The test's own NDJSON row in run - `30328748330` (`real` lane) reads `warnFound: false, ranBootstrap: false, pathLen: 312` -- the - scratch directory this specific CI run created did not actually reach a state where the - sub-bootstrap could run at all (most likely an OS-level path-length limit on the CI runner - itself was hit before `run_setup.bat`'s own ~200-char check ever got a chance to fire), yet the - test apparently tolerates this as a passing outcome rather than treating it as inconclusive. - This means the guard's actual WARN-firing behavior (`*** WARNING: Script path is chars. - Paths near 260 chars may cause cmd.exe failures.`) has NOT been positively confirmed by this - specific real-CI observation -- the demo doc's own Scenario 24 correctly labels the guard's - exact text `[Extrapolated Branch]`, cited from source, for this reason, rather than presenting - an inconclusive test result as if it were a real, confirmed capture. **Not fixed in this pass** - -- documentation-only task, and root-causing exactly why the scratch directory's own path - didn't reach the runner's practical limit (or why the test tolerates `ranBootstrap:false`) - needs its own investigation into `tests/selftest.ps1`'s long-path scenario, not a rushed fix - folded into an unrelated docs PR. Worth checking in a future pass whether this is a persistent, - reproducible gap (in which case the test's own pass/fail logic may need tightening) or a - one-off artifact of this particular run's CI-runner path layout. - -- **15. `:exe_smokerun_hints`'s diagnostic re-run of a freshly-failed EXE has no timeout, unlike - every other user-code launch point in this file -- found 2026-07-31, flagged by a CodeRabbit - review on PR #402 while documenting the hint mechanism for - `docs/demo-bootstrapper-output.md`'s Scenario 37, verified against source before logging.** - `:exe_smokerun_hints` (`run_setup.bat`) does `pushd dist` then `"%ENVNAME%.exe" > "~exe_out.txt" - 2>&1` with no `.NET Process`/`WaitForExit(ms)`/`Kill()` wrapper at all -- a plain, synchronous, - unbounded batch invocation. Every OTHER place in this file that launches a frozen EXE or the - interpreter (`:run_exe_smokerun`'s primary verification, `:run_failfast_probe`'s three call - sites, `:hidden_import_recover`'s own repair-check re-run) is deliberately bounded, either by - the ~30s hard-kill ceiling (the one place this bootstrapper is allowed to `Kill()` a process) or - by the fail-fast-probe's classify-then-never-kill design -- this is the one exception. The call - site's own guard (`if not "%HP_EXE_EXIT%"=="-1" call :exe_smokerun_hints`, in `:smokerun_ndjson`) - only confirms the FIRST launch (inside `:run_exe_smokerun`) exited with a real, non-hang code -- - it says nothing about whether the SAME program will behave identically on this SECOND, separate - launch a few lines later. A program with any non-determinism (a race condition, an environment - check that sometimes succeeds, or anything that occasionally blocks on stdin instead of exiting - fast) could hang this second, untimed invocation and hang the whole bootstrap, even though the - first invocation legitimately fell into the "fast, real, non-hang failure" classification that - is this subroutine's own precondition for being called at all. **Not fixed in this pass** -- - documentation-only task, and the correct fix (reuse the existing bounded-launch pattern, e.g. - `tools/exe_smokerun.ps1`'s own `.NET Process` + timeout wrapper, or add an independent - termination deadline before the stderr pattern-match) touches `run_setup.bat` itself, out of - scope for a docs-only PR. `docs/demo-bootstrapper-output.md`'s Scenario 37 was not changed to - caveat this specifically, since the two real captures it documents are both genuine, already- - completed runs (the hang risk is about a DIFFERENT, hypothetical program, not about the accuracy - of what's shown) -- but a future pass fixing this should also confirm no currently-passing test - silently relies on the unbounded behavior before adding a timeout. - ## Cold Storage (promising ideas, deliberately shelved -- revisit only if a named trigger fires) Moved to `docs/agent-cold-storage.md` (2026-07-31, to reduce this file's per-session context diff --git a/docs/agent-closed-backlog.md b/docs/agent-closed-backlog.md index b7f246a5..901d1d3e 100644 --- a/docs/agent-closed-backlog.md +++ b/docs/agent-closed-backlog.md @@ -755,6 +755,193 @@ this belongs to). opportunistically across this and future PRs' own `cache`-lane runs, not via a dedicated verification loop. +### Item 15 (closed 2026-08-01) + +- **`:exe_smokerun_hints`'s diagnostic re-run of a freshly-failed EXE had no timeout, unlike every + other user-code launch point in `run_setup.bat` -- found 2026-07-31, flagged by a CodeRabbit + review on PR #402, fixed 2026-08-01.** `:exe_smokerun_hints` did `pushd dist` then + `"%ENVNAME%.exe" > "~exe_out.txt" 2>&1` with no `.NET Process`/`WaitForExit(ms)`/`Kill()` + wrapper at all -- a plain, synchronous, unbounded invocation. Every other user-code/EXE launch + point in the file (`:run_exe_smokerun`'s primary verification, `:run_failfast_probe`'s three + call sites, `:hidden_import_recover`'s own repair-check re-run) is deliberately bounded, either + by the ~30s hard-kill ceiling or by the fail-fast-probe's classify-then-never-kill design. The + call site's own guard (`if not "%HP_EXE_EXIT%"=="-1" call :exe_smokerun_hints`) only confirms + the FIRST launch (inside `:run_exe_smokerun`) exited with a real, non-hang code -- it says + nothing about whether the SAME program behaves identically on this SECOND, separate launch a + few lines later. Any non-determinism (a race, an env check that sometimes succeeds, anything + that occasionally blocks on inherited stdin) could hang this second, untimed invocation and + hang the whole bootstrap, even though the FIRST invocation legitimately classified as "fast, + real, non-hang failure." + **Fixed** with a new dedicated helper, `tools/exe_hint_rerun.ps1` (embedded as + `HP_EXE_HINT_RERUN`, following the standard `sync_payload.py`/`emit_from_base64` embedded-helper + convention) -- deliberately NOT a reuse of `~exe_smokerun.ps1`'s activity-aware kill philosophy + (which skips the kill once any output has been observed, correct for a REAL verification run + worth waiting on indefinitely). This re-run is diagnostic-only, never shown live to the user, so + the new helper kills UNCONDITIONALLY at its deadline (`HP_HINT_RERUN_KILL_MS`, default 10000ms + -- a diagnostic capture on an already-failed run does not need the full 30s primary-verification + budget; test-only override, mirroring `HP_SMOKERUN_KILL_MS`'s established pattern). Output is + written as ONE combined stdout+stderr file (`~exe_out.txt`, matching the original `2>&1` merge + exactly), since the existing `findstr`-based hint-matching in `:exe_smokerun_hints` only checks + for substring presence, never which stream a line came from. See + `docs/agent-lessons-learned.md`'s "Fail-fast probe window vs. the ~30s hard-kill cap" entry for + the full three-helper comparison (`~exe_smokerun.ps1` / `~failfast_probe.ps1` activity-aware vs. + `~exe_hint_rerun.ps1` unconditional). + **Test coverage**: `tests/test_exe_hint_rerun.py` (new, mirrors `tests/test_exe_smokerun.py`'s + established harness -- `sys.executable` as the fake exe, logic fed via inherited stdin, real + `pwsh` subprocess). `UnconditionalKill.test_hang_after_output_is_ALSO_killed_unlike_exe_smokerun` + is the regression test that actually proves the defining behavioral difference from the other + two helpers in this file family: a process that prints once and then hangs is still killed here + (unlike `~exe_smokerun.ps1`, where the same shape would be left running unbounded). A + `PayloadSync` test confirms the embedded base64 matches the canonical source byte-for-byte + (CRLF/LF normalized, per the established `.ps1` PayloadSync convention). No existing test relied + on the old unbounded behavior (confirmed via grep for `exe_smokerun_hints`/`exe_out.txt` across + `tests/`), so nothing needed to change on the success path. + **A genuine gap in the first version of this fix was caught by CodeRabbit review on PR #410 + before merge: `Process.Kill()` only terminates the immediate tracked process, not its + descendants.** A PyInstaller onefile bootloader (or any program) that spawns a child inheriting + the redirected stdout/stderr handles can leave that child running after the immediate process + is killed -- the pipe then never reaches EOF, and the original unbounded + `ReadToEndAsync().Result` read would hang forever, defeating the entire point of adding a + bounded helper. Fixed two ways: (1) `taskkill /F /T /PID` (process-tree kill) instead of a bare + `Kill()` -- Windows PowerShell 5.1 targets .NET Framework, which has no + `Process.Kill(entireProcessTree)` overload (that's .NET 5+ only), so `taskkill /T` is the + available mechanism; (2) a bounded final read (`Task.Wait($drainMs)`, hardcoded 5000ms) as an + independent second safety net, so even a descendant `taskkill /T` somehow misses cannot hang + the helper indefinitely -- it degrades to partial/empty output instead. + `tests/test_exe_hint_rerun.py::ProcessTreeAndDrainTimeout` spawns a grandchild that inherits the + pipe and outlives its own immediate parent (via `subprocess.Popen(..., stdout=sys.stdout)`), + proving the helper still terminates within a bounded time instead of hanging -- when this test + was first written, the sandbox this fix was built in had no `taskkill.exe`, so it only proved + the drain-wait fallback; **now confirmed on real Windows CI too** (two lanes on PR #410 showed + the same test's `returncode`/timing assertions passing, meaning the `taskkill /T` path itself + was genuinely exercised there -- only the test's own `tempfile.TemporaryDirectory()` cleanup + failed afterward, a separate, Windows-only bug in the TEST fixed below, not in the production + helper). Also fixed a + second, smaller CodeRabbit finding in the same review: `:exe_smokerun_hints` never explicitly + set `HP_HINT_RERUN_OUT` before invoking the helper, relying on its default -- an inherited/leaked + value for that env var from elsewhere would silently redirect the helper's output away from the + file the hint-matching `findstr` checks actually read. Fixed by setting it explicitly + (`~exe_out.txt`) at the call site, matching `HP_HINT_RERUN_EXE`'s own set/clear pattern. + **A wall-clock timing assertion in the same test file proved genuinely unfixable by tuning the + bound, and had to be redesigned instead.** `UnconditionalKill`'s two tests asserted elapsed + time stayed under a fixed ceiling to prove `HP_HINT_RERUN_KILL_MS=500` was actually honored + (not silently ignored in favor of the 10000ms default). The first bound (8s) failed on real CI + at 9.235s; the revised bound (13s) then failed on THREE SEPARATE real-CI lanes on the very next + push, at 13.468s, 14.578s, and 16.328s respectively -- a moving target across runs, not + converging toward any stable value, most likely reflecting variable overhead from the new + `taskkill.exe` process-tree-kill call (no other helper in this file family spawns it, so there + was no prior timing data to draw from) compounded by ordinary load variance on a shared runner + deep into a long sequential test suite. **Fixed by removing the dependency on wall-clock time + entirely**: `tools/exe_hint_rerun.ps1` now writes its resolved `$killMs` directly to + `HP_HINT_RERUN_KILLMS_OUT` (default `~exe_hint_killms.txt`), unconditionally, right after + computing it -- production callers never read this file, so it costs nothing there. The tests + now assert on that value DIRECTLY (proving the override was read and used, deterministically, + immune to CI-runner noise) instead of inferring it from timing. A generous, untuned wall-clock + ceiling (45s, well below the underlying test scripts' own 120s sleep) is kept only as a coarse + sanity net against the kill mechanism being completely broken -- it is not expected to need + future adjustment for ordinary CI variance, since it no longer needs to distinguish "honored a + 500ms override" from "silently fell back to 10000ms," only "did the kill fire at all." + +### Item 12 (closed 2026-08-01) + +- **`:embed_dl_retry`'s genuine mid-download-failure-then-retry-once path (REQ-009 Tier 5) had no + CI test hook at all -- found 2026-07-29 while documenting the embed-tier download for + `docs/demo-bootstrapper-output.md`'s Part VI, Scenario 20, fixed 2026-08-01.** Only two test + hooks existed for this tier -- `HP_TEST_FORCE_EMBED_FAIL` (immediate decline, no download + attempted) and `HP_TEST_FORCE_EMBED_REAL` (a full, real, successful download end-to-end). + Neither exercised the retry branch: `:embed_dl_retry`'s own `[WARN] embed fallback: download + failed; retrying once.` line (both curl and PowerShell failing on the FIRST try, succeeding on + the second) was reachable in principle but never observed firing in any real CI run. + **Fixed** with a new one-shot test hook, `HP_TEST_FORCE_EMBED_DL_FAIL_ONCE=1`, mirroring the + existing `HP_TEST_FORCE_CONDA_CREATE_NETWORK_FAIL`-style one-shot-then-succeed pattern already + used for REQ-022's conda-create retry -- deterministically fails ONLY the first download + attempt (no network touched) and clears itself immediately, so the second attempt always goes + through for real. `:embed_dl_retry` was restructured to share its "retry vs. give up" decision + (`if %HP_EMBED_DL_ATTEMPT% LSS 2 (...)`) between the real-failure path and the test-hook path + via a new shared `:embed_dl_attempt_failed` label, rather than duplicating that logic. + **Test coverage**: `self.embed.dl.retry` (new, `tests/selfapps_ux_hardening.ps1`), combining + the new one-shot-fail hook with the existing `HP_TEST_FORCE_EMBED_REAL=1` (same narrow + `HP_OFFLINE_MODE` hole as the sibling `.real` scenario) so the SECOND, real attempt genuinely + downloads/extracts/verifies/runs -- asserts both the `[TEST] HP_TEST_FORCE_EMBED_DL_FAIL_ONCE:` + hook-fired line and the `[WARN] ... retrying once.` line appear, AND that the tier still + succeeds end-to-end afterward (proving retry-then-succeed, not just retry-then-give-up). Skips + with `skip=true` in the conda-full lane, same reasoning as `.decline`/`.real`. Registered in + `docs/agent-ndjson.md`'s row registry per this file's own AGENT DIRECTIVE. + +### Item 10 (closed 2026-08-01) + +- **Two of the five `PVW_*` super-user override variables (`PVW_PYTHON_EXE`, `PVW_WORKSPACE`) had + ZERO test coverage of any kind, and ALL FIVE had zero coverage of their invalid-value behavior + -- found 2026-07-29 while documenting them for `docs/demo-bootstrapper-output.md`'s Part V, + fixed 2026-08-01.** `PVW_UV_EXE` and `PVW_TARGET_PY` each had real, valid-value CI coverage + incidental to another test's own purpose; `PVW_CONDA_EXE` had dedicated coverage + (`self.corrupt.conda.override_exit`); `PVW_PYTHON_EXE`/`PVW_WORKSPACE` had none at all, and no + test anywhere exercised an INVALID value for any of the five. + **Fixed** with a new file, `tests/selfapps_pvw_overrides.ps1` (uv lane only, non-gating), + covering the item's own suggested shape in full: both currently-zero-coverage variables' + valid-value paths (the cheapest, highest-value gap), plus 2 representative invalid-value + scenarios (not the full 5x2 combinatorial matrix, per the item's own "2-3 representative cases + would likely cover the real risk" reasoning) -- + - `self.pvw.python_exe.valid` -- a two-stage test: stage 1 does an ordinary uv bootstrap purely + to materialize a real, working interpreter; stage 2 is a genuinely fresh scratch directory + (so the EXE-cache fast path cannot short-circuit past `:after_env_mode_selection`, where the + override actually applies) running a different stub app with `PVW_PYTHON_EXE` pointed at + stage 1's interpreter -- confirms the override log line fires and the app runs successfully + via the borrowed interpreter. + - `self.pvw.workspace.valid` -- a fresh bootstrap with `PVW_WORKSPACE` set to a custom + directory -- confirms the debug override log line fires, `Scripts\python.exe` exists at the + CUSTOM path (not the default `.uv_env`), the default env was never created, and the app + still runs successfully from the relocated venv. + - `self.pvw.python_exe.invalid` -- `PVW_PYTHON_EXE` set to a nonexistent path -- confirms the + pre-existing interpreter smoke test right after the override + (`"%HP_PY%" -c "print('py_ok')" ... || (... [WARN] Interpreter smoke test failed + (continuing). ...)`) absorbs the broken value gracefully, exactly as the original static + trace predicted -- no uncontrolled crash. + - `self.pvw.workspace.invalid` -- `PVW_WORKSPACE` set to a path already occupied by a plain + file (uv cannot create a venv "inside" a file) -- confirms the failure cascades to the SAME + already-established `:uv_venv_fail` -> conda-create fallback every other uv-venv-creation + failure in this file already goes through, rather than a raw, unhandled failure. Deliberately + does NOT require the conda fallback to actually succeed, only that it's reached -- a real + Miniconda download in this lane is an already-accepted cost (see `self.conda.bothfail`'s own + precedent). + All four skip with `skip=true` in the conda-full lane (uv is never the provider there). + **Remaining invalid-value combinations for `PVW_UV_EXE`/`PVW_TARGET_PY` (and any beyond the + `PVW_CONDA_EXE` case already covered by `self.corrupt.conda.override_exit`) are deliberately + NOT built** -- the original finding's own reasoning (the failure-absorption mechanism is + shared/generic across most of these, so a combinatorial 5x2 matrix would mostly duplicate the + same proof) still applies; revisit only if a real-world trigger surfaces a gap the + representative cases above don't actually cover. + +### Item 13 (closed 2026-08-01) + +- **`self.warn.longpath`'s own real CI run showed an INCONCLUSIVE result (`ranBootstrap:false`), + yet the test still reported an overall pass -- found 2026-07-29 while documenting the + path-length pre-flight guard for `docs/demo-bootstrapper-output.md`'s Part VI, Scenario 24, + root-caused and fixed 2026-08-01.** The original finding's NDJSON row (run `30328748330`, + `real` lane) read `warnFound: false, ranBootstrap: false, pathLen: 312`. **Root cause + (`tests/selftest.ps1`'s long-path scenario)**: `$lpRanBootstrap` is only set `$true` right + after the `cmd /c "call run_setup.bat ..."` line executes without PowerShell itself throwing; + the surrounding `try { Push-Location $longDir; ... } catch { $lpRanBootstrap = $false }` means + a `Push-Location` failure (thrown BEFORE `cmd /c` is ever reached) lands in the catch block + with `$lpRanBootstrap` still at its initial `$false`. `Push-Location` throws here because + default GitHub-hosted Windows runners do not have `LongPathsEnabled` turned on, so + PowerShell's own CWD-navigation cannot enter a >260-char directory at all -- `run_setup.bat`'s + own long-path guard is never reached, genuinely never confirming the WARN fires. + **Confirmed PERSISTENT, not a one-off**: a second real CI run on PR #410 (months after the + original finding) showed the byte-identical signature (`ranBootstrap:false, warnFound:false, + pathLen:312, pass:true`) -- same runner limitation, same scratch-path length, same outcome. + **Fixed**: `$lpPass`/the NDJSON row now distinguish three outcomes instead of two -- + bootstrap ran and the WARN fired (real pass/fail on `$lpWarnFound`/`$lpExit`), bootstrap could + not even be attempted but the scratch path was verified long enough (now reported as + `skip=true, reason='runner-cannot-navigate-long-path'` rather than a plain, overstated pass -- + mirrors this repo's established skip pattern for "infra could not reach the code path under + test," e.g. the conda-not-installed-uv-first pattern in `docs/agent-interconnect.md`), or the + scratch path itself was not built long enough (a genuine test-setup bug, still a hard FAIL). + `docs/demo-bootstrapper-output.md`'s Scenario 24 was already correctly labeling the WARN text + `[Extrapolated Branch]` for this exact reason -- no change needed there, since this fix only + makes the NDJSON row's own `pass`/`skip` fields honestly reflect what the earlier investigation + had already concluded from source. + ## Closed Backlog - **Cascade-vs-postexec fix (Active Backlog item 9), 2026-07-25, owner-directed follow-up to a diff --git a/docs/agent-interconnect.md b/docs/agent-interconnect.md index b3e765bf..8348924e 100644 --- a/docs/agent-interconnect.md +++ b/docs/agent-interconnect.md @@ -21,65 +21,48 @@ this file immediately in the SAME commit (edit existing entries, do not only app ## Concurrent-instance lock (REQ-024) touches every exit path -- call-graph tracing method -The lock (`:acquire_lock`/`:lock_is_stale`/`:release_lock`, called near the top of the file -right after `%STATUS_FILE%` cleanup, released at `:die` and `:success`) is a cross-cutting -concern: **any future change that adds a NEW top-level process-terminating `exit /b` site, or -converts an existing `call`-based subroutine into a `goto`-only continuation of the main line, -must consider whether that site also needs `call :release_lock` before it.** This section -documents the call-graph tracing method used to scope the lock's release points, and its limits, -so a future agent doesn't have to rediscover them. - -**Why release is hooked at `:die`/`:success` only, not all ~100 `exit /b` sites.** Before this -feature, `run_setup.bat` had 129 `:label` definitions and 55 top-level (unindented) `exit /b` -sites (100 total once indented/parenthesized ones are included). A naive count of lines matching -`exit /b` (including ones indented inside parenthesized blocks) is not a reliable proxy for -"process-terminating site" -- whether a given `exit /b` actually terminates the whole process or just -returns from a subroutine depends on the RUNTIME call stack at that point, not on whether the -label it lexically sits under was ever `call`ed -- CMD.EXE's `exit /b` returns to the nearest -active `call` frame, and a label reached purely via `goto` from *inside* an active call frame -(e.g. `:venv_canary_fail`, reached by `goto` from within `:try_venv_fallback`'s call frame) still -returns to `:try_venv_fallback`'s caller, not the whole process. A static, line-based CFG walk -(BFS over `goto`/`call`/fall-through edges, attempted while designing this feature) correctly -proves depth for pure goto/call/fall-through code, but breaks down at parenthesized -`if (...) ( ... ) else ( ... )` blocks: a bare `exit /b N` line lexically inside such a block is -only reached when the block's condition is true, and the walker has no notion of "skip to the -line after the matching close-paren" for the false branch without a full paren-balance parser -- -early lines 45-52 (`if not exist "%~dp0" ( ... exit /b 1 )`) demonstrated this: the naive walker -treated that `exit /b 1` as *always* reached and stopped exploring right there, never proving -reachability for the other ~4000 lines that obviously do execute in a normal run. Building a true -paren-aware CFG parser was assessed as disproportionate effort for this feature. - -**`tools/audit_batch_exit_paths.py`** (new, not wired into CI -- run by hand) captures the -label/call/exit inventory this section is based on: it lists every label, every `call :label` -site, and every `exit /b` site with its containing label and whether that label is ever `call`ed -anywhere in the file. It explicitly does NOT attempt the paren-aware CFG proof described above -- -its own module docstring documents the same "called=False is a hint, not proof" limitation. Use -it as the starting point for a future exit-path audit (e.g. before reworking this lock feature, -or before adding a new top-level consent gate), not as a final answer on its own. - -**The practical, sufficient answer instead:** CMD has no `finally`/`trap`, so a lock design that -depends on *proving* every exit path releases it is the wrong shape regardless of how good the -audit is -- a `Ctrl+C`, a killed process, a power loss, or a genuinely-missed exit site all bypass -any release hook unconditionally. The correct backstop is age-based staleness (already the -design), so the release hooks at `:die`/`:success` are an optimization (avoid leaving a stale -lock around for up to ~2 hours after an ordinary successful run or an ordinary handled failure), -not the correctness mechanism. Both labels were independently confirmed as the two universal -funnels for the normal flow: every `call`-based consent-gate decline (REQ-014 system-Python -consent, REQ-009 cascade consent, REQ-013 connectivity decline, the venv canary probe, etc.) -returns up its call chain and continues the bootstrap (possibly into a further fallback tier) -rather than terminating the process directly, and the EXE fast path's early success shortcut -(`:try_fast_exe`, called at line ~294, near the top of the file) also funnels through `:success` -(confirmed separately in the "EXE fast path vs env-state fast path" section below). The three -handful of truly-early, pre-label `exit /b 1` sites (workspace-path-invalid guards, lines ~47/52) -execute before the lock is ever acquired (lock acquisition is placed intentionally AFTER those -checks), so they need no release call by construction. - -**If you add a new top-level (non-`call`ed) `exit /b` site to the main line in the future**, ask -whether it can be reached with an empty call stack (i.e., directly off a `goto` from the main -line, not from inside any `call`ed subroutine's continuation chain). If so, and if it is a -*routine* exit that a real user could hit often (not a crash), add `call :release_lock` before it --- the same way `:die` and `:success` do -- so a normal repeated-decline pattern doesn't leave -stale locks for other users of the same folder to wait out. +The lock (`:acquire_lock`/`:lock_is_stale`/`:release_lock`, called near the top of the file right +after `%STATUS_FILE%` cleanup, released at `:die` and `:success`) is cross-cutting: **any new +top-level process-terminating `exit /b` site, or any conversion of a `call`-based subroutine into +a `goto`-only continuation of the main line, must consider whether that site also needs +`call :release_lock` before it.** + +**Why release is hooked at `:die`/`:success` only, not all ~100 `exit /b` sites** (`run_setup.bat` +has 129 `:label`s, 55 top-level `exit /b` sites, 100 total including indented/parenthesized ones): +whether a given `exit /b` terminates the whole process or just returns from a subroutine depends +on the RUNTIME call stack, not on whether its label was ever `call`ed -- `exit /b` returns to the +nearest active `call` frame, and a label reached purely via `goto` from inside an active call frame +(e.g. `:venv_canary_fail`, reached from within `:try_venv_fallback`'s frame) still returns to that +caller, not the whole process. A static line-based CFG walk (BFS over goto/call/fall-through edges) +proves depth for pure goto/call/fall-through code but breaks on parenthesized +`if (...) ( ... ) else ( ... )` blocks -- it has no notion of "skip to after the matching +close-paren" for the false branch without a full paren-balance parser, so it wrongly treats any +`exit /b` lexically inside such a block as always-reached. A true paren-aware CFG parser was +assessed as disproportionate effort. + +**`tools/audit_batch_exit_paths.py`** (not wired into CI, run by hand) captures the label/call/exit +inventory instead: every label, every `call :label` site, every `exit /b` site with its containing +label and whether that label is ever `call`ed anywhere. It deliberately does NOT attempt the +paren-aware CFG proof -- its own docstring documents "called=False is a hint, not proof." Starting +point for a future exit-path audit, not a final answer. + +**The actual correctness mechanism is age-based staleness, not proven release-path coverage** -- +CMD has no `finally`/`trap`, so a design depending on proving every exit path releases the lock is +the wrong shape (Ctrl+C, a killed process, power loss, or a missed exit site all bypass any release +hook). The `:die`/`:success` hooks are an optimization (avoid a stale lock for up to ~2h after an +ordinary run), not the guarantee. Both are confirmed universal funnels for the normal flow: every +`call`-based consent-gate decline (REQ-014, REQ-009 cascade, REQ-013 connectivity, venv canary, +etc.) returns up its call chain and continues the bootstrap rather than terminating directly, and +the EXE fast path's early-success shortcut (`:try_fast_exe`, ~line 294) also funnels through +`:success` (see "EXE fast path vs env-state fast path" below). The few truly-early, pre-label +`exit /b 1` sites (workspace-path-invalid guards, ~lines 47/52) execute before the lock is ever +acquired, so need no release call. + +**Rule for a new top-level (non-`call`ed) `exit /b` site**: if it's reachable with an empty call +stack (directly off a `goto` from the main line, not from inside any `call`ed subroutine's +continuation chain) AND is a *routine* exit a real user could hit often (not a crash), add +`call :release_lock` before it -- same as `:die`/`:success` -- so a repeated-decline pattern +doesn't leave stale locks for other users of the same folder. --- @@ -88,440 +71,306 @@ stale locks for other users of the same folder to wait out. **Touch either subroutine, must understand the other.** `:try_nuitka_tier_a` (AV-Safe Build Path requirements 2-4, `docs/prd-av-safe-build-path.md`) attempts a Nuitka fallback build when PyInstaller's own build fails; on success it sets `HP_NUITKA_FALLBACK_USED=1` and -`dist\.exe` is a Nuitka-produced binary, not a PyInstaller one. `:hidden_import_recover` +`dist\.exe` becomes a Nuitka-produced binary, not a PyInstaller one. `:hidden_import_recover` (REQ-016 Slice 2, see `docs/agent-lessons-learned.md`'s "--hidden-import auto-recovery must stay -STRICT" entry) is the ONLY post-build repair mechanism this bootstrapper has for a frozen EXE -that fails at runtime with a `ModuleNotFoundError` for an installed module -- and its repair -action is a PyInstaller-specific rebuild (`--hidden-import=X`), which does not apply to a -Nuitka-built EXE at all (Nuitka has its own, structurally different `--include-module`/ -`--follow-import-to` mechanism, not wired up here). - -**Real bug found and fixed via a refinement pass, 2026-07-21 (same day Tier A shipped):** -`:hidden_import_recover` had no check for `HP_NUITKA_FALLBACK_USED` before its first line of real -work -- it would unconditionally re-run the EXE, scan for a fixable `ModuleNotFoundError`, and (if -found) rebuild via `PyInstaller -y --onefile ... --hidden-import=X`, silently discarding the -Nuitka-built `dist\.exe` and replacing it with a fresh PyInstaller attempt. Since Tier A only -runs when the ORIGINAL PyInstaller build already failed once this run, this PyInstaller rebuild -inside the recovery loop had a real chance of reproducing the exact failure Tier A exists to route -around (e.g. AV quarantine), or at minimum wasting the loop's 3-attempt budget on the wrong tool -while leaving the working Nuitka EXE's fate undefined mid-rebuild. `HP_NUITKA_FALLBACK_USED` is -process-global and safely readable at `:hidden_import_recover`'s entry point (only one `setlocal` -exists in the whole file, at the very top, disabling delayed expansion -- no scoping boundary sits -between the build block that sets the variable and this subroutine). - -**Fix**: `:hidden_import_recover` now checks `if defined HP_NUITKA_FALLBACK_USED` immediately -after its existing `if not exist "dist\%ENVNAME%.exe" exit /b 0` early-return, and exits `/b 0` -immediately (skip, not attempt-and-fail) with an `[INFO][HIDDEN_IMPORT]` log line explaining why. -This is deliberately a SKIP, not a Nuitka-aware repair -- wiring up Nuitka's own missing-import -recovery mechanism is out of scope for this fix and would be its own future feature if ever -needed. Regression test: `tests/selfapps_nuitka_tiera_hidden_skip.ps1` (uv lane, non-gating, +STRICT" entry) is the ONLY post-build repair mechanism for a frozen EXE that fails at runtime with +a `ModuleNotFoundError` for an installed module -- its repair action is a PyInstaller-specific +rebuild (`--hidden-import=X`), which does not apply to a Nuitka-built EXE (Nuitka has its own, +structurally different `--include-module`/`--follow-import-to` mechanism, not wired up here). + +**Bug found and fixed (2026-07-21, same day Tier A shipped):** `:hidden_import_recover` had no +check for `HP_NUITKA_FALLBACK_USED` before its first real work -- it would unconditionally re-run +the EXE, scan for a fixable `ModuleNotFoundError`, and rebuild via `PyInstaller -y --onefile ... +--hidden-import=X`, silently discarding the Nuitka-built EXE for a fresh PyInstaller attempt. Since +Tier A only runs when the ORIGINAL PyInstaller build already failed, this rebuild had a real chance +of reproducing the exact failure Tier A exists to route around (e.g. AV quarantine), or at minimum +burning the loop's 3-attempt budget on the wrong tool. `HP_NUITKA_FALLBACK_USED` is process-global +and safely readable at `:hidden_import_recover`'s entry (only one `setlocal` exists in the whole +file, at the top, disabling delayed expansion -- no scoping boundary between the build block that +sets it and this subroutine). + +**Fix**: `:hidden_import_recover` now checks `if defined HP_NUITKA_FALLBACK_USED` right after its +existing `if not exist "dist\%ENVNAME%.exe" exit /b 0` early-return, and exits `/b 0` immediately +(skip, not attempt-and-fail) with an `[INFO][HIDDEN_IMPORT]` log line -- deliberately a SKIP, not a +Nuitka-aware repair (wiring up Nuitka's own missing-import mechanism is a future feature if ever +needed). Regression test: `tests/selfapps_nuitka_tiera_hidden_skip.ps1` (uv lane, non-gating, `self.exe.tiera.hidden_skip`) -- forces Tier A via `HP_TEST_FORCE_PYINSTALLER_FAIL=1`, lets a real Nuitka build succeed, and has the stub app print a FABRICATED `ModuleNotFoundError: No module named 'nuitka'` to stderr before exiting 1 (`nuitka` is guaranteed -installed in the exact build interpreter Tier A just used, so the scanner's `find_spec` gate would -treat it as fixable if the skip guard were missing or broken) -- then asserts the skip log line -fires and the OLD `[REPAIR][HIDDEN_IMPORT] Adding --hidden-import=` rebuild line does NOT. - -**The warnfix-triggered rebuild (a SECOND PyInstaller rebuild call site, inside the -`HP_WARNFIX_NEEDED` block) had the mirror-image gap, found via a later bug-hunt review pass: no -failure handling at all, and no `HP_NUITKA_FALLBACK_USED` clearing on success.** Unlike the -original build a few dozen lines earlier (which routes every failure through `:try_nuitka_tier_a` -/ `:die` / `HP_BOOTSTRAP_STATE=error`), the warnfix rebuild's `"%HP_PY%" -m PyInstaller ...` -call had no `if errorlevel 1` check at all -- the very next line unconditionally logged -`[REPAIR] rebuild complete after warnfix.` regardless of whether it actually did, and nothing -re-checked `dist\%ENVNAME%.exe`. A genuine rebuild failure here (e.g. the exact AV-lock class the -whole PRD exists to route around) fell through to `:run_exe_smokerun`'s silent no-op-when-missing -skip, then a clean interpreter-fallback run, ending in a false `state=ok`. Fixed with the same -`if errorlevel 1 (...) else if not exist "dist\%ENVNAME%.exe" (...) else (...)` shape, nested -if/else (no goto, matching the "safe inside a parenthesized block" pattern the original build's -own comment documents) -- **deliberately NOT retried via `:try_nuitka_tier_a`** unlike the -original build: this rebuild only exists to bundle an already-installed warnfix module into an -EXE that was already confirmed working before this attempt, so the conservative, honest response -to a failure is to report it via `HP_BOOTSTRAP_STATE=error`, not to speculatively invoke a second -build tool inside an already-nested failure path. On a SUCCESSFUL rebuild, the fix also clears -`HP_NUITKA_FALLBACK_USED` -- this rebuild always uses PyInstaller, so if the EXE it just replaced -was previously Nuitka-built (a stale PyInstaller `build\%ENVNAME%\warn-%ENVNAME%.txt` can survive -a Tier-A-rescued build and still trigger this warnfix block), the flag must reflect that the file -at `dist\%ENVNAME%.exe` is now genuinely PyInstaller-built, or `:hidden_import_recover`'s own -guard above would wrongly keep skipping repair on it. No dedicated CI test added for this specific -fix (a review-pass correctness fix reusing an already-thoroughly-tested failure-handling shape, -not a new feature) -- flagged in CLAUDE.md as a candidate for a future dedicated test pass if this -path's real-world trigger rate ever justifies it. - -**If a future Tier B (requirement 5, reprovisioned pinned-3.12 environment, dropped from the -backlog -- see CLAUDE.md's Known Findings) or any other alternate-build-tool path is added, it -needs this same guard.** The check belongs on -`HP_NUITKA_FALLBACK_USED` specifically (or an equivalent "the EXE currently at `dist\.exe` -was NOT built by PyInstaller" signal) -- any future subroutine that can produce `dist\.exe` -via something other than PyInstaller should set an analogous marker and this guard should be -extended to check it, not just the one existing flag. +installed in Tier A's own build interpreter, so the scanner's `find_spec` gate would treat it as +fixable if the skip guard were missing) -- asserts the skip log fires and the OLD +`[REPAIR][HIDDEN_IMPORT] Adding --hidden-import=` rebuild line does NOT. + +**Mirror-image gap found via a later bug-hunt pass, in the warnfix-triggered rebuild (a SECOND +PyInstaller rebuild call site, inside `HP_WARNFIX_NEEDED`): no failure handling at all, and no +`HP_NUITKA_FALLBACK_USED` clearing on success.** Unlike the original build (which routes every +failure through `:try_nuitka_tier_a`/`:die`/`HP_BOOTSTRAP_STATE=error`), this rebuild's +`"%HP_PY%" -m PyInstaller ...` call had no `if errorlevel 1` check -- the next line unconditionally +logged `[REPAIR] rebuild complete after warnfix.` regardless of outcome, and nothing re-checked +`dist\%ENVNAME%.exe`. A genuine failure here (the exact AV-lock class the PRD exists to route +around) fell through to `:run_exe_smokerun`'s silent no-op-when-missing skip, then a clean +interpreter-fallback run, ending in a false `state=ok`. Fixed with the same +`if errorlevel 1 (...) else if not exist "dist\%ENVNAME%.exe" (...) else (...)` nested if/else +shape (no goto, matching the "safe inside a parenthesized block" pattern) -- **deliberately NOT +retried via `:try_nuitka_tier_a`**: this rebuild only bundles an already-installed warnfix module +into an EXE already confirmed working, so the conservative response to failure is +`HP_BOOTSTRAP_STATE=error`, not a speculative second build tool inside an already-nested failure +path. On SUCCESS the fix also clears `HP_NUITKA_FALLBACK_USED` -- this rebuild always uses +PyInstaller, so if the EXE it just replaced was previously Nuitka-built (a stale +`build\%ENVNAME%\warn-%ENVNAME%.txt` can survive a Tier-A-rescued build and still trigger this +block), the flag must reflect the file is now genuinely PyInstaller-built, or +`:hidden_import_recover`'s guard above would wrongly keep skipping repair. No dedicated CI test +(a review-pass correctness fix reusing an already-tested failure-handling shape) -- flagged in +CLAUDE.md as a candidate for a future dedicated test pass if this path's trigger rate justifies it. + +**Any future Tier B or alternate-build-tool path needs this same guard**, on +`HP_NUITKA_FALLBACK_USED` specifically (or an equivalent "the EXE at `dist\.exe` was NOT built +by PyInstaller" signal) -- any subroutine that can produce `dist\.exe` via something other +than PyInstaller should set an analogous marker and extend this guard to check it. --- ## AV-Safe Build Path requirement 9 (`:offer_optimized_build`) -- a strictly safer sibling of Tier A **Shares `:try_nuitka_tier_a`'s Nuitka invocation but NOT its "delete first, build second" safety -posture, and that difference is the entire point.** Tier A runs only after the PyInstaller build -already failed, so it is free to `del "dist\%ENVNAME%.exe"` before attempting a replacement -- -there is nothing working to lose. `:offer_optimized_build` (requirement 9, called from -`:smokerun_ndjson` right after `call :run_postexec_checkpoint exe`, before `HP_EXE_EXIT` is -cleared) runs in the OPPOSITE situation: PyInstaller already succeeded AND the EXE already passed -its smoke-test verification. A design that deleted/overwrote `dist\%ENVNAME%.exe` up front (Tier -A's own pattern) would risk losing a confirmed-working build to an elective, human-declinable -upsell -- unacceptable. Instead it builds to a distinct temp name +posture -- that difference is the entire point.** Tier A runs only after PyInstaller already +failed, so it's free to `del "dist\%ENVNAME%.exe"` first -- nothing working to lose. +`:offer_optimized_build` (requirement 9, called from `:smokerun_ndjson` right after +`call :run_postexec_checkpoint exe`, before `HP_EXE_EXIT` is cleared) runs in the OPPOSITE +situation: PyInstaller already succeeded AND the EXE already passed smoke-test verification. +Deleting/overwriting `dist\%ENVNAME%.exe` up front would risk losing a confirmed-working build to +an elective, declinable upsell. Instead it builds to a distinct temp name (`%ENVNAME%.optimized_build.exe`), runs its own internal 30s-capped verification launch against -that temp file (same `ProcessStartInfo`/`WaitForExit(30000)`/`Kill()`-on-timeout pattern -`:run_exe_smokerun` uses), and only on CONFIRMED build-and-run success does `move /y` swap it over -the original. Every failure branch (`goto :optbuild_cleanup`) deletes only the temp file and -leaves `dist\%ENVNAME%.exe` completely untouched -- see the subroutine's own header comment in -`run_setup.bat` for the full branch-by-branch trace. - -**Real bug found and fixed via a refinement-pass code review, same day as PR #370 merged: the -post-swap "did it work" check tested the wrong file.** The original code checked `if not exist -"dist\%ENVNAME%.exe"` after the `move /y` to decide whether the swap succeeded -- but -`dist\%ENVNAME%.exe` is the DESTINATION, which is the already-working original EXE this whole -subroutine exists to (maybe) replace, so it already exists BEFORE the move runs, success or -failure alike. A same-volume `move /y` onto an existing destination is an atomic rename-replace: -on success the SOURCE (`dist\%HP_OPTBUILD_TMP%`) is consumed; on failure (e.g. an AV/indexer lock -on the destination -- the exact hazard class already documented immediately below for -`:try_embed_fallback`'s own `rd`/`move` swap) the whole operation is rejected and the source is -left untouched, with the destination unaffected either way. The old destination-existence check -could therefore never actually detect a failed swap -- a real failure would be silently -misreported as `[INFO] Optimized build succeeded and verified...`, `HP_NUITKA_FALLBACK_USED` -would be wrongly set (incorrectly disabling `:hidden_import_recover`'s auto-recovery for what is -still a PyInstaller-built EXE, per the section above), and the leftover temp file at -`dist\%HP_OPTBUILD_TMP%` would never be cleaned up (the old failure branch didn't route through -`:optbuild_cleanup` either). Fixed by checking whether the SOURCE is gone instead, and by routing -this failure through the shared `:optbuild_cleanup` label like every other failure branch (which -also fixes the temp-file leak as a side effect). New test hook -`HP_TEST_FORCE_OPTBUILD_SWAP_FAIL` (skips the real `move` and deliberately leaves the temp file in -place, reproducing the exact "source still exists after move" failure signature without depending -on an artificial OS-level file lock) and a new `swapfail` scenario in -`tests/selfapps_optimized_build.ps1` (uv lane, non-gating, real Nuitka build + verify, like -`accept`) prove the fix: the original EXE is left completely untouched and still runs, the -leftover temp file is cleaned up, and the success message never logs. - -**Gated on the SAME `HP_NUITKA_FALLBACK_USED` flag Tier A sets, but for the opposite reason.** -Tier A's hidden-import-recovery guard (section above) checks the flag to SKIP a PyInstaller-only -repair mechanism against a Nuitka EXE. This subroutine checks the flag to SKIP OFFERING THE PROMPT -AT ALL when Tier A already ran -- if `dist\%ENVNAME%.exe` is already a Nuitka build, requirement -9's "want an optimized version too?" question doesn't apply (the user already has one). On its own -success path it sets `HP_NUITKA_FALLBACK_USED=1` too, for the same semantic reason (the file at -`dist\%ENVNAME%.exe` genuinely is now Nuitka-built) -- this happens late enough in the flow -(after `:run_exe_smokerun`/`:hidden_import_recover` have already completed for this pass) that it -has no retroactive effect on anything in the SAME run, but keeps the flag accurate for any code -that might read it later. - -**Consent-gate pattern mirrors `:run_postexec_checkpoint`'s BROADER 4-way auto-decline set, not -the narrower 3-branch gates elsewhere in this file** (`:system_build_consent_gate` etc.) -- see -that subroutine's own header comment for why: like the checkpoint, this prompt fires on -essentially every successful bootstrap run (not a narrow edge-case path), so it also auto-declines -on `NOINPUT`/`HP_NONINTERACTIVE` in addition to `HP_CI_LANE`, matching `:compute_interactive_run`'s -own authoritative non-interactivity signals elsewhere in this file. - -**Cascade re-entry can offer this prompt more than once per bootstrap, same as the checkpoint.** -Since this is called from the exact same call site as `:run_postexec_checkpoint exe` (right after -it), a REQ-009 provider cascade that reaches a NEW verification pass under a different provider -tier re-offers BOTH prompts fresh each time -- intentional, not a bug, for the same reason -documented in the "Post-execution checkpoint" section below (each cascade tier is a genuinely -different build). - -**Nuitka/MSVC auto-detection research (informs the reactive-only hint text on failure, added to -both this subroutine and `:try_nuitka_tier_a`):** confirmed via Nuitka's own GitHub issue tracker -(Nuitka/Nuitka#3317) that Nuitka auto-detects an installed Visual Studio via the registry with NO -need to run from a Developer Command Prompt or set up `vcvarsall.bat` first -- a user with a plain -VS2022 install and the "Desktop development with C++" workload (which includes a Windows SDK by -default) should have Nuitka "just work" via MSVC with zero extra setup. Deliberately did NOT add -any proactive detection/fingerprinting of the user's installed compiler to decide behavior -- -research Finding 2 in `docs/prd-av-safe-build-path.md` already argued against that class of -fingerprinting, and this repo's Tier A design (requirement 4) already committed to trusting -Nuitka's own internal discovery entirely. The hint text is REACTIVE ONLY (a static `[WARN]` line -that only prints after Nuitka's OWN build already failed), never a proactive nag shown on a -successful run or unconditionally on every bootstrap -- this keeps the "probing isn't a great -idea" principle intact (no active detection of what's on the machine) while still surfacing a -genuinely actionable, low-noise hint exactly when it would help. +that temp file (same `ProcessStartInfo`/`WaitForExit(30000)`/`Kill()`-on-timeout pattern as +`:run_exe_smokerun`), and only on CONFIRMED build-and-run success does `move /y` swap it over the +original. Every failure branch (`goto :optbuild_cleanup`) deletes only the temp file, leaving +`dist\%ENVNAME%.exe` untouched -- see the subroutine's own header comment for the full trace. + +**Bug found and fixed (same day PR #370 merged): the post-swap "did it work" check tested the +wrong file.** The original code checked `if not exist "dist\%ENVNAME%.exe"` after `move /y` -- +but that's the DESTINATION, the already-working original EXE, which exists BEFORE the move +regardless of outcome. A same-volume `move /y` onto an existing destination is an atomic +rename-replace: on success the SOURCE is consumed; on failure (e.g. an AV/indexer lock on the +destination -- same hazard class as `:try_embed_fallback`'s own `rd`/`move` swap below) the whole +operation is rejected, source untouched, destination unaffected. The destination-existence check +could therefore never detect a failed swap -- a real failure was silently misreported as +`[INFO] Optimized build succeeded and verified...`, `HP_NUITKA_FALLBACK_USED` wrongly set +(incorrectly disabling `:hidden_import_recover`'s auto-recovery for what's still a +PyInstaller-built EXE), and the leftover temp file never cleaned up. Fixed by checking whether the +SOURCE is gone instead, routed through the shared `:optbuild_cleanup` label like every other +failure branch (fixing the temp-file leak too). New test hook +`HP_TEST_FORCE_OPTBUILD_SWAP_FAIL` (skips the real `move`, leaves the temp file in place, +reproducing the "source still exists after move" failure signature without an OS-level lock) and +a new `swapfail` scenario in `tests/selfapps_optimized_build.ps1` (uv lane, non-gating, real +Nuitka build) prove the fix. + +**Gated on the SAME `HP_NUITKA_FALLBACK_USED` flag Tier A sets, for the opposite reason.** Tier +A's hidden-import-recovery guard checks the flag to SKIP a PyInstaller-only repair against a +Nuitka EXE; this subroutine checks it to SKIP OFFERING THE PROMPT AT ALL when Tier A already ran +(the "want an optimized version too?" question doesn't apply -- the user already has one). On its +own success path it sets `HP_NUITKA_FALLBACK_USED=1` too, for the same semantic reason -- this +happens late enough (after `:run_exe_smokerun`/`:hidden_import_recover` already completed for this +pass) to have no retroactive effect within the same run, but keeps the flag accurate later. + +**Cascade re-entry can offer this prompt more than once per bootstrap, same as the checkpoint** -- +called from the same site as `:run_postexec_checkpoint exe`, so a REQ-009 cascade reaching a new +verification pass under a different provider tier re-offers BOTH prompts fresh each time +(intentional -- each cascade tier is a genuinely different build; see "Post-execution checkpoint" +below). + +**Consent-gate pattern mirrors `:run_postexec_checkpoint`'s BROADER 4-way auto-decline set** (not +the narrower 3-branch gates elsewhere, e.g. `:system_build_consent_gate`) -- like the checkpoint, +this fires on essentially every successful run, so it also auto-declines on +`NOINPUT`/`HP_NONINTERACTIVE` in addition to `HP_CI_LANE`, matching `:compute_interactive_run`'s +authoritative non-interactivity signals. + +**Nuitka/MSVC auto-detection research** (informs the reactive-only hint text on failure, in both +this subroutine and `:try_nuitka_tier_a`): confirmed via Nuitka/Nuitka#3317 that Nuitka +auto-detects an installed Visual Studio via the registry with NO need for a Developer Command +Prompt or `vcvarsall.bat` -- a plain VS2022 + "Desktop development with C++" install should "just +work" via MSVC. Deliberately NOT proactive fingerprinting of the installed compiler (research +Finding 2 in `docs/prd-av-safe-build-path.md` already argued against that class of probing, and +Tier A already commits to trusting Nuitka's own discovery). The hint text is REACTIVE ONLY (a +static `[WARN]` after Nuitka's OWN build already failed), never a proactive nag. --- ## Standalone Python-download tier (REQ-009 Tier 5 by naming/history; executed 3rd as of the ## provider-chain reorder below, SHIPPED) -**Status: implemented 2026-07.** `:try_embed_fallback` (new subroutine in `run_setup.bat`) is -wired into both fallback ladders, a new `HP_ENV_MODE=embed` value flows through every call site -identified during the original design audit, and CI coverage lives in -`tests/selfapps_ux_hardening.ps1` (`self.embed.fallback.decline`, `self.embed.fallback.real` -- -see `docs/agent-ndjson.md`). This section is kept as the map of the `HP_ENV_MODE` blast radius -this tier touches -- useful for anyone extending or debugging it later, not just for the original -implementer. The original design-map audit covered 34 `HP_ENV_MODE` reference sites in -`run_setup.bat`; re-verify line numbers against the current file before relying on them, since -they will have moved. - -**Provider-chain reorder (follow-up pass): `uv -> conda -> embed -> venv -> system`, embed moved -from last-resort to right after conda.** Originally embed was the final rung (reached only after -uv, conda, venv, AND system had all failed). Reordered so a user who pinned a specific Python -version via `runtime.txt`/`pyproject.toml` still gets it via a fresh checksummed python.org -download when uv/conda are unreachable, instead of silently falling back to whatever's already -ambient on the machine (venv/system just wrap the ambient interpreter, they cannot acquire a -different one) -- conda and embed both front-load acquisition of a FRESH/pinned interpreter; -venv/system stay the true last resort. System stays absolute final regardless, since it is the -only tier gated by the REQ-014 consent prompt (touches the user's real environment). - -Both dispatch mechanisms that encode provider order moved together: `:handle_conda_failure` (the -linear, unconditional initial fallback chain -- reorders its three `call` blocks from -venv/system/embed to embed/venv/system) and `:provider_cascade` (the goto-based, re-entrant -post-warnfix cascade -- `:cascade_from_conda` now targets `:try_embed_fallback` instead of -`:try_venv_fallback`; a new `:cascade_from_embed` label + `HP_CASCADE_TRIED_EMBED` guard targets -`:try_venv_fallback`; `:cascade_from_venv` is unchanged, still targets `:try_system_fallback`; -`:cascade_from_system` and `HP_CASCADE_TRIED_SYSTEM` were deleted entirely, since system has no -cascade target now -- exactly mirroring how embed had none before this reorder). Confirmed via a -full repo-wide trace that no downstream consumer of `HP_ENV_MODE`/`HP_ENV_READY` needed to change: -every consumer does pure exact-string-equality on `HP_ENV_MODE`, and `HP_ENV_READY` is a -tier-agnostic boolean -- only the two dispatch chains and their explanatory comments needed to -move. Tier numbering ("Tier 4" = system, "Tier 5" = embed) was deliberately kept as a historical/ -naming label, not renumbered to match new execution order -- renumbering would touch ~15 comment -sites plus two docs files for zero functional benefit, since the load-bearing NDJSON `id` fields -(`self.embed.fallback.decline`/`.real`) are not tier-numbered. - -**Correctness bug found and fixed in the same follow-up pass: the version-swap mechanism below -was dead code.** The "PowerShell stage extracts latest, Python stage swaps to the user's -requested version if different" design (point 2 below) had never actually executed, in -production or in CI. `run_setup.bat`'s version-check-and-swap sequence was wrapped in one -parenthesized `if not errorlevel 1 ( ... )` block; a `for /f` loop inside that block set -`HP_EMBED_SWAP_DIR`/`_TAG`/`_MINOR` from the Python stage's output, and code later in the SAME -block read `%HP_EMBED_SWAP_DIR%` to decide whether to swap -- but CMD's parse-time `%VAR%` -expansion substitutes every `%VAR%` in a parenthesized block using the value from BEFORE the -block began, not a value a `for /f` loop set during the same block's own execution (the exact bug -class documented in `docs/agent-lessons-learned.md`'s "Provider-cascade dispatch is goto-based on -purpose"). Since `HP_EMBED_SWAP_DIR` was never set earlier in the subroutine, that read was -always empty, so the swap body never ran regardless of what version was actually requested. No -test caught it because `self.embed.fallback.real` never requests a non-default version through -this tier. Fixed via goto-based dispatch, matching this file's established fix pattern for this -bug class -- see the "Two real implementation gotchas" list below (gotcha 1, the `._pth` patch) -for the OTHER pre-existing hazard this tier has to handle correctly, which was unaffected by -this bug. - -**Second correctness issue found via a later deep-dive review pass -- NOT the same fix shape as -requirement 9, despite an initial attempt to mirror it, because this is a DIRECTORY move, not a -FILE move.** `:embed_swap_retry` checked `if exist "%HP_EMBED_DIR%\python.exe"` after `rd /s /q -"%HP_EMBED_DIR%"` + `move /y "%HP_EMBED_SWAP_DIR%" "%HP_EMBED_DIR%"` to decide whether the swap -succeeded. A first pass "fixed" this by checking whether the SOURCE was gone instead, mirroring -requirement 9's own swap-verification fix -- but that mirroring does not actually hold here. -Requirement 9's fix works because a same-volume FILE `move /y` onto an existing destination is -atomic: it either fully replaces the destination (source consumed) or fully fails (source -untouched, destination unaffected) -- no third outcome. A DIRECTORY `move` onto a destination -that still exists behaves differently: it silently NESTS the source inside the destination -(`HP_EMBED_SWAP_DIR` ends up living at `HP_EMBED_DIR\`, not replacing -`HP_EMBED_DIR`'s contents). If `rd /s /q` fails to fully clear `HP_EMBED_DIR` (the exact -AV/indexer-lock race this code already anticipates), the destination still exists when `move` -runs, so nesting occurs -- and in that scenario BOTH candidate checks read as false success: the -STALE prior `python.exe` is still sitting at `HP_EMBED_DIR`'s top level regardless of what the -nested move did (so "does the destination exist" is wrong), and `HP_EMBED_SWAP_DIR` as an exact -path also no longer exists, since it got renamed away into the nested subfolder rather than -genuinely swapped (so "is the source gone" is ALSO wrong, for a different reason). Neither -post-hoc check can distinguish "swap genuinely succeeded" from "rd failed and the source got -silently nested instead." - -**The actually-correct fix: gate `move` on `rd` having genuinely cleared the destination first,** -so `move` only ever runs onto a nonexistent target (pure rename semantics, nesting structurally -impossible) -- which is what makes a post-hoc destination check reliable again. `:embed_swap_retry` -now does `rd /s /q "%HP_EMBED_DIR%"` then `if exist "%HP_EMBED_DIR%" goto :embed_swap_rd_failed` -(skip `move` entirely and go straight to the retry-count check) before ever attempting `move`; only -when `rd` is confirmed to have cleared the directory does `move` run, and only then is `if exist -"%HP_EMBED_DIR%\python.exe"` trustworthy as a success signal. **This is NOT CI-confirmed** -- -`self.embed.fallback.real` still never requests a non-default Python version through this tier -(same gap the first correctness bug's own fix description already noted), so the swap branch this -check lives in remains untested by any real CI run; the fix is based on static reasoning about -Windows `move`/`rd` semantics (verified against documented Windows directory-move behavior, not a -live Windows reproduction from this sandbox). Building a dedicated test (pinning a non-latest -Python version through a real embed-tier download) would be the natural next step if this tier's -real-world trigger rate ever justifies the investment -- not undertaken here, matching this tier's -existing low-priority status elsewhere in this repo's own research notes. - -**Refinement made during implementation, beyond the original design map: two-stage -PowerShell/Python split, not a single PowerShell script.** The original design map (below) -assumed one implementation stage. Building it surfaced a chicken-and-egg problem the design map -had not addressed: this tier runs precisely when NO Python interpreter exists anywhere on the -system (the scenario it exists for), so per-request version-table logic cannot live in Python -until *some* interpreter is on disk. The shipped design splits the tier into two stages: -1. **PowerShell stage** (`tools/embed_extract.ps1`, embedded as `HP_EMBED_EXTRACT`) -- batch has - already downloaded ONE hardcoded "latest" version's zip (`HP_EMBED_LATEST_PATCH` / - `HP_EMBED_LATEST_SHA256` constants near the top of `run_setup.bat`). This script does ONLY - checksum verification (`Get-FileHash`), extraction (`Expand-Archive`), and the `._pth` patch - (uncommenting `#import site`) -- zero per-request branching. Prints the extracted - `python.exe` path on success, exits 1 silently on any failure. -2. **Python stage** (`tools/embed_pyver_check.py`, embedded as `HP_EMBED_PYVER_CHECK`) -- runs - under the fresh interpreter stage 1 just extracted. This is the ONLY place per-request version - logic lives, reusing the same `PYSPEC` value `~detect_python.py` already computed earlier in - the bootstrap (the same value uv/conda already honor) instead of re-deriving equivalent regex - logic a second time in PowerShell. If `PYSPEC` requests a minor other than the table's - "latest" entry, it re-fetches/verifies/extracts the correct version itself via stdlib - (`urllib.request`, `hashlib`, `zipfile`) -- not PowerShell a second time. - - `EMBED_PYTHON_TABLE` (in `embed_pyver_check.py`) maps minor -> `(patch, sha256)` for 3.10 - through 3.14 (5 entries, matching the design map's "python.org's currently-supported (non-EOL) - CPython minors" scope decision below). The `"3.14"` entry's patch/sha256 MUST match the - batch-side `HP_EMBED_LATEST_PATCH`/`HP_EMBED_LATEST_SHA256` constants exactly -- a refresh that - updates one but not the other is caught by `tests/test_embed_tier.py`'s - `BatchPythonConsistency` test, not discovered live. - -**Windows self-file-lock hazard (discovered during implementation, not in the original design -map): a running process cannot delete or replace its own executable/DLLs.** The Python stage -(stage 2 above) runs FROM `HP_EMBED_DIR` (the directory stage 1 just extracted into) -- so if a -version swap is needed, extracting the replacement directly into `HP_EMBED_DIR` while that same -process is still running fails on Windows file locks. Fix: the Python stage extracts any swap -into a SIBLING staging directory (`HP_EMBED_DIR` + `_swap`), never into its own running directory. -The actual directory swap (`rd /s /q` old + `move /y` new-into-place) happens in the BATCH caller, -only AFTER the `for /f ... in ('powershell ... -File "~embed_pyver_check.py" ...')` call has -returned -- i.e., only after the Python process has fully exited and released its locks. If you -ever refactor this to invoke the Python stage differently (e.g. inline instead of via `for /f` -capturing its own exit), re-verify the swap still happens strictly after process exit. - -**Offline-mode test-flag exception chain -- touches two call sites, not one.** A CI test that -wants to exercise the REAL embed download (`HP_TEST_FORCE_EMBED_REAL=1`) needs to punch a hole -through `HP_OFFLINE_MODE=1` at BOTH: `:try_embed_fallback`'s own offline check, AND -`:download_get_pip` (reused from the REQ-023b venv-resilience work to bootstrap pip into the -embed interpreter), whose existing offline exception only recognized -`HP_TEST_FORCE_VENV_CREATE_FAIL`. `:download_get_pip`'s check was extended to an OR of both flags -via an intermediate `HP_GETPIP_SKIP_OFFLINE` variable (batch has no clean single-line boolean OR -without delayed expansion, which is off-limits here -- see "CMD.EXE 8191-Character Line Limit" -and the delayed-expansion ban elsewhere in `docs/agent-lessons-learned.md`). If a future test -needs `:download_get_pip`'s real download exercised under a THIRD new test flag, both call sites -must be extended together, or the new flag will be silently blocked by the second one even after -successfully bypassing the first. - -**CMD line-length budget was hit for real building this.** The first draft of -`embed_pyver_check.py` (comments, docstrings, nested-dict table) produced a base64 line of 9439 -chars -- over the 8191 hard limit (see "CMD.EXE 8191-Character Line Limit" in -`docs/agent-lessons-learned.md`) by 1248 chars. Trimmed to 6739 chars (comments condensed, -docstrings replaced with inline comments, table changed from nested dicts to tuples) with a -1452-char safety margin. If you extend `EMBED_PYTHON_TABLE` with more minors or add features, -re-check the budget with the same method documented in that lessons-learned entry before assuming -it still fits. - -**Sigstore was evaluated and rejected for this tier's integrity check, in favor of embedded -SHA256.** Verifying via Sigstore would require `cosign` or `sigstore-python`, both of which -themselves require an existing Python/tool installation -- circular for a tier whose entire -purpose is "no Python exists yet." Embedded SHA256 (`HP_EMBED_LATEST_SHA256` / the per-minor -`EMBED_PYTHON_TABLE` entries, computed once at pin-time and verified independently against real -downloads before shipping) is proportionate and consistent with this repo's "bootstrap -reliability > API correctness" architecture principle (see CLAUDE.md). - -**Original design-map audit below, preserved as the source of the confirmed call-site list.** -This was produced by auditing every `HP_ENV_MODE` reference in `run_setup.bat` (34 sites at audit -time) and classifying each as "needs a new `embed` case" vs. "already provider-agnostic, works -automatically." - -**Design decisions already agreed with the user (2026-07):** -- No REQ-014-style consent gate. Unlike system Python (which uses a shared, uncontrolled, - version-unknown ambient environment), the embeddable zip is a private, checksummed, - bootstrapper-controlled extraction under `~embed_python\` -- more REQ-010-isolated than the - system tier, not less. Progress logging (REQ-016-style), not a prompt. -- Version selection: a small pinned per-minor-version table (`{"3.11": "3.11.9", ...}`), NOT a - single hardcoded fallback version and NOT a live python.org scrape for "latest" (violates this - repo's "deterministic execution > dynamic resolution" principle -- same reasoning as the - pipreqs 0.4.13 pin). Table scope: python.org's currently-supported (non-EOL) CPython minor - versions at time of refresh -- practically ~5-6 entries, refreshed on the same quarterly - cadence as the pipreqs pin and other "Periodic Maintenance Checks" entries. A request older - than the table's floor (an EOL version) falls back to the table's oldest entry with a WARN, - rather than the table growing back indefinitely. The orchestration-layer default (no user - version request) always uses the table's *newest* entry, mirroring "orchestration always uses - latest" already established for uv (`UV_PYTHON_PREFERENCE=only-managed`). -- Integrity: embed the expected SHA256 per pinned version directly in the bootstrapper (computed - once at pin-time), not fetched from a checksum file at runtime over the same network path as - the download itself. -- Two real implementation gotchas to not lose: (1) the embeddable zip ships with `site` imports - disabled via the `pythonXY._pth` file -- must uncomment/edit that file before pip or any - installed package is importable at all, or the tier will look like it succeeded while being - silently broken. (2) the embeddable zip has no pip -- reuse the existing `:download_get_pip` - subroutine (built for the REQ-023b venv-resilience work), do not write a second copy. - -**Mental model: embed behaves like `venv`, not like `system`.** venv and embed are both -"fully isolated, bootstrapper-installable Python environments" (safe to `pip install` into -freely); system is "shared, minimally-invasive" (installs are avoided/consent-gated). Wherever -the code below branches system out to a restricted/no-op path, embed should NOT be excluded the -same way -- it should fall through and behave like venv/uv/conda's normal path instead. - -**Confirmed call sites requiring a genuinely new `embed` case (the real work):** -1. `:handle_conda_failure` (~line 1787) -- the INITIAL fallback chain, reached whenever conda - itself fails to install/create at bootstrap (both the uv-fails-then-conda-also-fails path and - the conda-only entry path funnel here). Currently: `call :try_venv_fallback` -> if that fails, - `call :try_system_fallback` -> if that also fails, `exit /b 0` (gives up, caller falls to - `:die`). Add a third rung: `call :try_embed_fallback` after the system-fallback attempt, - before the final `exit /b 0`. `HP_FORCE_CONDA_ONLY=1` already short-circuits this whole - function before any fallback is attempted, so embed is automatically suppressed under that - flag with no extra check needed. -2. `:provider_cascade`'s dispatch (~line 1618-1632) -- the SEPARATE post-build, warnfix-driven - cascade (re-attempts the dependency phase under the next tier when an already-built EXE has - unresolved imports and the user consents via `:cascade_consent_gate`). Currently dispatches - `uv -> conda -> venv -> system` via `if /i "%HP_ENV_MODE%"=="" goto :cascade_from_` - lines, then a final "tiers exhausted" catch-all. Needs: a new - `if /i "%HP_ENV_MODE%"=="system" goto :cascade_from_system` case ahead of the catch-all, a new - `:cascade_from_system` label (mirroring `:cascade_from_venv`'s shape exactly: guard on a new - `HP_CASCADE_TRIED_SYSTEM` var, call `:try_embed_fallback`, `goto :after_env_mode_selection` on - success or a new `:cascade_embed_unavailable` label on failure). Per the existing "no-loop - guarantee" rule in this doc: the new guard var is mandatory, and embed must be the last tier - (no further cascade target), which falls out naturally by not adding an `embed` case to the - dispatch `if` chain -- reaching `embed` there falls through to the existing exhausted - catch-all. -3. **New subroutine `:try_embed_fallback`** (does not exist yet) -- mirrors `:try_venv_fallback` - / `:try_system_fallback`'s exact shape and is called from BOTH sites above (both entry ladders - -- venv/system already have this dual-entry pattern, embed must match it, not just implement - one path). On success: `set "HP_ENV_MODE=embed"`, `set "HP_PY="`, `set "HP_BOOTSTRAP_STATE=embed_env"` (new state value, matching venv's - `venv_env` / system's `degraded_env` pattern), leave `HP_SKIP_PIPREQS` UNSET (matching venv, - NOT system -- system sets `HP_SKIP_PIPREQS=1` specifically because pipreqs requires - `pip install pipreqs` into a shared/uncontrolled environment it's trying to avoid touching; - that reasoning does not apply to embed's private extraction), log - `[BOOT] REQ-009: Selected Python provider: Embedded Python (python.org).`, `exit /b 0`. -4. Dependency-install branch inside `:after_env_mode_selection`'s requirement-install block - (~line 1258-1303) -- `if "%HP_ENV_MODE%"=="conda" (...) else if "%HP_ENV_MODE%"=="venv" (...) - else if "%HP_ENV_MODE%"=="uv" (...) else ( call :log "[WARN] System fallback: skipping - requirement installation." )`. **This is the single most important site to get right**: the - final `else` is currently reached by `system` mode ON PURPOSE (conservative, no-install - behavior for a shared environment) -- but it would ALSO silently catch `embed` mode if no new - branch is added, meaning embed would build with zero declared dependencies installed and rely - 100% on the warnfix safety net. Add `else if "%HP_ENV_MODE%"=="embed" (...)` before the final - catch-all `else`, with the exact same body as the `venv` branch (plain - `"%HP_PY%" -m pip install -r requirements.txt`, no `--python` flag needed unlike uv, no conda - channel logic). - -**Confirmed call sites that already work automatically, zero code change needed** (each was -individually verified, not assumed): the pipreqs-install uv-vs-else branch (~line 962, else -branch is plain `%HP_PY% -m pip install`, already works once embed's pip is bootstrapped); the -heuristic-augmentation conda-vs-else branch (~line 1258, same reasoning); the pip-freeze capture -uv-vs-else branch (~line 1307); the PyInstaller-install uv-vs-else branch (~line 2623); the -`:compute_collect_flags` system-only exclusion (~line 2744, embed should NOT be excluded here, -and isn't, since no `embed` string ever matches the existing `if "%HP_ENV_MODE%"=="system"` check --- confirms by omission, not by addition); the REQ-007 "build under every provider except system" -gate (~line 2593, same reasoning -- embed builds automatically); the `:append_env_mode_row` NDJSON -emitter (~line 1946, reads `HP_ENV_MODE` dynamically, no hardcoded branch); both smoke-test log -lines that interpolate `%HP_ENV_MODE%` directly as display text (~lines 2799, 2819); the -`:conda_base_update` conda-only guard (~line 3457, correctly excludes embed already, no conda -involved); dep-check's fast-path gate (~line 1231, `if not "%HP_ENV_MODE%"=="conda" if not -"%HP_ENV_MODE%"=="uv" goto :dep_check_done` -- **deliberately** left excluding embed too, matching -venv's existing scope; this is a design choice to keep embed symmetric with venv for MVP, not an -oversight -- the dep-check fast-path skip-on-repeat-run optimization could be extended to embed -in a later round if desired, but isn't required for parity). - -**Pre-existing gap discovered during this audit, unrelated to embed, flagged not fixed:** the -warnfix REPAIR-install branch (~line 2661, inside the missing-modules-detected block) only has -two cases -- `if "%HP_ENV_MODE%"=="uv" (...)` and `else if defined CONDA_BAT (...)` -- with NO -plain-pip fallback for any other mode. This means venv and system modes ALREADY have a silent -blind spot today: if warnfix detects missing modules under venv or system, neither branch matches -(uv is false, `CONDA_BAT` is undefined since conda was never touched), so the repair loop is a -no-op and the rebuild proceeds with the same missing modules still missing. This is a real, -pre-existing bug independent of the embed-tier work. **Confirmed unchanged after shipping embed** -(the `~line 2661` warnfix REPAIR-install branch was not touched by the Tier 5 PR): embed inherits -the identical gap, matching venv's/system's existing behavior, not introducing a new one. Still -worth a dedicated future fix (add a plain-`%HP_PY% -m pip install` catch-all branch covering -venv/system/embed alike) -- remains its own backlog item, not folded into the Tier 5 work. +**Status: implemented 2026-07.** `:try_embed_fallback` is wired into both fallback ladders, a new +`HP_ENV_MODE=embed` value flows through every call site identified during the original design +audit (34 `HP_ENV_MODE` reference sites; re-verify line numbers before relying on them), and CI +coverage lives in `tests/selfapps_ux_hardening.ps1` (`self.embed.fallback.decline`, +`self.embed.fallback.real` -- see `docs/agent-ndjson.md`). This section maps the `HP_ENV_MODE` +blast radius this tier touches. + +**Provider-chain reorder: `uv -> conda -> embed -> venv -> system`, embed moved from last-resort +to right after conda.** Originally embed was the final rung. Reordered so a user who pinned a +specific Python version via `runtime.txt`/`pyproject.toml` still gets it via a fresh checksummed +python.org download when uv/conda are unreachable, instead of silently falling back to whatever's +ambient (venv/system just wrap the ambient interpreter, they cannot acquire a different one) -- +conda and embed both front-load acquisition of a FRESH/pinned interpreter; venv/system stay the +true last resort. System stays absolute final regardless (only tier gated by the REQ-014 consent +prompt). Both dispatch mechanisms encoding provider order moved together: `:handle_conda_failure` +(linear initial fallback chain, reordered venv/system/embed -> embed/venv/system) and +`:provider_cascade` (goto-based re-entrant post-warnfix cascade -- `:cascade_from_conda` now +targets `:try_embed_fallback`; new `:cascade_from_embed` + `HP_CASCADE_TRIED_EMBED` guard targets +`:try_venv_fallback`; `:cascade_from_venv` unchanged; `:cascade_from_system`/ +`HP_CASCADE_TRIED_SYSTEM` deleted, since system has no cascade target now). No downstream +`HP_ENV_MODE`/`HP_ENV_READY` consumer needed to change (pure exact-string-equality / tier-agnostic +boolean) -- only the two dispatch chains moved. Tier numbering ("Tier 4"=system, "Tier 5"=embed) +was deliberately kept as a historical label, not renumbered to match execution order (the +load-bearing NDJSON ids are not tier-numbered). + +**Bug found+fixed in the reorder pass: the version-swap mechanism (stage 2 below) was dead code.** +The version-check-and-swap sequence was wrapped in one parenthesized `if not errorlevel 1 ( ... )` +block; a `for /f` loop inside set `HP_EMBED_SWAP_DIR`/`_TAG`/`_MINOR`, and code later in the SAME +block read `%HP_EMBED_SWAP_DIR%` -- but CMD's parse-time `%VAR%` expansion substitutes every +`%VAR%` using the value from BEFORE the block began (the bug class in +`docs/agent-lessons-learned.md`'s "Provider-cascade dispatch is goto-based on purpose"), so the +read was always empty and the swap body never ran. No test caught it (`self.embed.fallback.real` +never requests a non-default version). Fixed via goto-based dispatch. + +**Second bug (later deep-dive): a DIRECTORY move, not a FILE move -- requirement 9's swap-fix +shape does NOT transfer.** `:embed_swap_retry` checked `if exist "%HP_EMBED_DIR%\python.exe"` +after `rd /s /q "%HP_EMBED_DIR%"` + `move /y "%HP_EMBED_SWAP_DIR%" "%HP_EMBED_DIR%"`. A first pass +mirrored requirement 9's "check whether the source is gone" fix -- but that only holds for a FILE +move (atomic: fully replaces or fully fails, no third outcome). A DIRECTORY `move` onto a still- +existing destination silently NESTS the source inside it instead. If `rd /s /q` fails to fully +clear `HP_EMBED_DIR` (an AV/indexer-lock race this code already anticipates), the destination +still exists when `move` runs, so nesting occurs -- and BOTH candidate checks then read as false +success (stale `python.exe` still at the top level; `HP_EMBED_SWAP_DIR` no longer exists either, +since it got renamed into the nested subfolder, not genuinely swapped). Neither post-hoc check can +distinguish "swap succeeded" from "rd failed and the source got silently nested." + +**Actual fix: gate `move` on `rd` having genuinely cleared the destination first**, so `move` only +ever runs onto a nonexistent target (pure rename, nesting structurally impossible) -- which makes a +post-hoc destination check reliable again. `:embed_swap_retry` now does `rd /s /q "%HP_EMBED_DIR%"` +then `if exist "%HP_EMBED_DIR%" goto :embed_swap_rd_failed` (skip `move`, go straight to the +retry-count check) before ever attempting `move`. **NOT CI-confirmed** -- `self.embed.fallback.real` +still never requests a non-default version through this tier, so this branch is untested by any +real CI run; the fix is static reasoning about documented Windows `move`/`rd` semantics, not a live +Windows repro. A dedicated test (pin a non-latest version through a real embed download) would be +the natural next step if this tier's trigger rate ever justifies it. + +**Two-stage PowerShell/Python split (not a single script), a refinement found during +implementation.** This tier runs precisely when NO Python interpreter exists anywhere, so +per-request version-table logic cannot live in Python until *some* interpreter is on disk: +1. **PowerShell stage** (`tools/embed_extract.ps1`, `HP_EMBED_EXTRACT`) -- batch has already + downloaded ONE hardcoded "latest" version's zip (`HP_EMBED_LATEST_PATCH`/ + `HP_EMBED_LATEST_SHA256` near the top of `run_setup.bat`). Does ONLY checksum verification + (`Get-FileHash`), extraction (`Expand-Archive`), and the `._pth` patch (uncommenting + `#import site`) -- zero per-request branching. Prints the extracted `python.exe` path on + success, exits 1 silently on failure. +2. **Python stage** (`tools/embed_pyver_check.py`, `HP_EMBED_PYVER_CHECK`) -- runs under the fresh + interpreter stage 1 just extracted. The ONLY place per-request version logic lives, reusing the + same `PYSPEC` value `~detect_python.py` already computed (the value uv/conda already honor). If + `PYSPEC` requests a minor other than the table's "latest," re-fetches/verifies/extracts the + correct version itself via stdlib (`urllib.request`, `hashlib`, `zipfile`). + `EMBED_PYTHON_TABLE` maps minor -> `(patch, sha256)` for 3.10-3.14 (5 entries, matching + python.org's currently-supported non-EOL minors). The `"3.14"` entry's patch/sha256 MUST match + the batch-side `HP_EMBED_LATEST_PATCH`/`HP_EMBED_LATEST_SHA256` exactly -- + `tests/test_embed_tier.py`'s `BatchPythonConsistency` test catches drift. + +**Windows self-file-lock hazard (a running process cannot delete/replace its own EXE/DLLs).** The +Python stage runs FROM `HP_EMBED_DIR` (the directory stage 1 extracted into), so extracting a +version swap directly into `HP_EMBED_DIR` while that process is still running fails on file locks. +Fix: the Python stage extracts any swap into a SIBLING staging directory (`HP_EMBED_DIR` + `_swap`) +-- the actual swap (`rd /s /q` old + `move /y` new-into-place) happens in the BATCH caller, only +AFTER the `for /f ... in ('powershell ... -File "~embed_pyver_check.py" ...')` call returns (i.e. +after the Python process has fully exited and released its locks). Re-verify this ordering if the +Python stage is ever invoked differently. + +**Offline-mode test-flag exception chain touches two call sites, not one.** `HP_TEST_FORCE_EMBED_ +REAL=1` needs a hole punched through `HP_OFFLINE_MODE=1` at BOTH `:try_embed_fallback`'s own +offline check AND `:download_get_pip` (reused from REQ-023b, whose existing offline exception only +recognized `HP_TEST_FORCE_VENV_CREATE_FAIL`) -- extended via an intermediate +`HP_GETPIP_SKIP_OFFLINE` OR variable (batch has no clean single-line boolean OR without delayed +expansion). A future third test flag needing `:download_get_pip`'s real download must extend both +sites together. + +**CMD line-length budget was hit for real.** `embed_pyver_check.py`'s first draft (docstrings, +nested-dict table) produced a 9439-char base64 line, 1248 over the 8191 hard limit. Trimmed to +6739 chars (condensed comments, inline comments instead of docstrings, tuples instead of nested +dicts) -- 1452-char margin. Re-check the budget before extending `EMBED_PYTHON_TABLE`. + +**Sigstore was evaluated and rejected for integrity, in favor of embedded SHA256.** Sigstore needs +`cosign`/`sigstore-python`, which themselves need an existing Python/tool install -- circular for a +tier whose whole purpose is "no Python exists yet." Embedded SHA256 (computed once at pin-time, +verified independently against real downloads before shipping) matches this repo's "bootstrap +reliability > API correctness" principle. + +**Design decisions agreed with the user:** +- No REQ-014-style consent gate -- the embeddable zip is a private, checksummed, + bootstrapper-controlled extraction under `~embed_python\`, more REQ-010-isolated than system, + not less. Progress logging (REQ-016-style), not a prompt. +- Version selection: a small pinned per-minor table, NOT a single hardcoded fallback and NOT a + live python.org "latest" scrape (violates "deterministic execution > dynamic resolution," same + reasoning as the pipreqs 0.4.13 pin). Table scope: python.org's currently-supported (non-EOL) + minors (~5-6 entries), refreshed on the same quarterly cadence as the pipreqs pin. A request + older than the table's floor falls back to the oldest entry with a WARN. No-request default uses + the table's *newest* entry, mirroring uv's `UV_PYTHON_PREFERENCE=only-managed`. +- Integrity: embed the expected SHA256 per pinned version directly (computed at pin-time), never + fetched from a checksum file over the same network path as the download. +- Two implementation gotchas: (1) the embeddable zip ships with `site` imports disabled via + `pythonXY._pth` -- must uncomment before pip/any installed package is importable, or the tier + looks like it succeeded while silently broken. (2) the embeddable zip has no pip -- reuse + `:download_get_pip` (built for REQ-023b), don't write a second copy. + +**Mental model: embed behaves like `venv`, not `system`.** venv and embed are both "fully +isolated, bootstrapper-installable" (safe to `pip install` into freely); system is "shared, +minimally-invasive" (installs avoided/consent-gated). Wherever code branches system out to a +restricted/no-op path, embed should NOT be excluded the same way. + +**Call sites wired to a new `embed` case (current, post-reorder state -- matches "Provider cascade +execution re-enters env-create" below, which documents the same two dispatch chains in full):** +1. `:handle_conda_failure` (~line 1787) -- the initial fallback chain. `call :try_embed_fallback` + runs right after conda itself fails, BEFORE venv/system are attempted (embed/venv/system order). + `HP_FORCE_CONDA_ONLY=1` already short-circuits this function, so embed is auto-suppressed. +2. `:provider_cascade`'s dispatch (~line 1618-1632) -- the separate post-build warnfix cascade. + `:cascade_from_conda` targets `:try_embed_fallback` (guard `HP_CASCADE_TRIED_EMBED`); a new + `:cascade_from_embed` targets `:try_venv_fallback`. System has NO cascade target of its own + (`:cascade_from_system` was deleted in the same reorder -- system is terminal). Per the + no-loop-guarantee rule: each guard var is mandatory and the order stays monotonic. +3. **New subroutine `:try_embed_fallback`** -- mirrors `:try_venv_fallback`/`:try_system_fallback`'s + shape, called from both sites above. On success: `HP_ENV_MODE=embed`, `HP_PY=`, `HP_BOOTSTRAP_STATE=embed_env`, leaves `HP_SKIP_PIPREQS` UNSET (matching venv, + not system -- system's `HP_SKIP_PIPREQS=1` exists specifically to avoid `pip install pipreqs` + into a shared environment, which doesn't apply to embed's private extraction), logs `[BOOT] + REQ-009: Selected Python provider: Embedded Python (python.org).`, `exit /b 0`. +4. Dependency-install branch in `:after_env_mode_selection` (~line 1258-1303) -- **the single most + important site**: the final catch-all `else` is reached by `system` mode ON PURPOSE (no-install + for a shared environment) but would ALSO silently catch `embed` if no new branch is added, + leaving embed with zero installed deps, relying 100% on the warnfix safety net. Added + `else if "%HP_ENV_MODE%"=="embed" (...)` before the catch-all, same body as `venv` (plain + `"%HP_PY%" -m pip install -r requirements.txt`). + +**Call sites that already worked automatically, zero change needed** (individually verified): the +pipreqs-install/heuristic-augmentation/pip-freeze-capture/PyInstaller-install uv-vs-else branches +(each plain-else already works once embed's pip is bootstrapped); `:compute_collect_flags`'s +system-only exclusion (embed never matches `"%HP_ENV_MODE%"=="system"`, confirmed by omission); +the REQ-007 "build under every provider except system" gate; `:append_env_mode_row`'s NDJSON +emitter (reads `HP_ENV_MODE` dynamically); both `%HP_ENV_MODE%`-interpolating smoke-test log +lines; `:conda_base_update`'s conda-only guard. Dep-check's fast-path gate (`if not +"%HP_ENV_MODE%"=="conda" if not "%HP_ENV_MODE%"=="uv" goto :dep_check_done`) **deliberately** +still excludes embed too, matching venv's scope -- a design choice for MVP parity, not an +oversight; could extend to embed later if desired. + +**Pre-existing gap found during this audit, unrelated to embed, flagged not fixed:** the warnfix +REPAIR-install branch (~line 2661) only has two cases -- `if "%HP_ENV_MODE%"=="uv"` and +`else if defined CONDA_BAT` -- with NO plain-pip fallback for any other mode. venv and system +ALREADY had this blind spot (warnfix-detected missing modules under either mode match neither +branch, so the repair loop is a no-op). Confirmed unchanged after shipping embed (this branch +wasn't touched) -- embed inherits the identical gap. Worth a dedicated future fix (a plain +`%HP_PY% -m pip install` catch-all covering venv/system/embed) -- its own backlog item. --- ## uv-First Provider Architecture -The "uv-first" feature (skip Miniconda download when uv can provide Python) has a larger -blast radius than it appears. This section documents how it touches test infrastructure. +The "uv-first" feature (skip Miniconda download when uv can provide Python) has a larger blast +radius than it appears -- this section documents how it touches test infrastructure. ### Provider selection flow (run_setup.bat) @@ -541,34 +390,27 @@ When `HP_UV_PROVIDING_PYTHON=1`: ### Provider cascade execution re-enters env-create (REQ-009/REQ-005.10 slice 3) `:provider_cascade` (reached from the main line via `if defined HP_CASCADE_APPROVED goto -:provider_cascade`, just after `:run_entry_smoke` returns) re-attempts the dependency phase -under the next provider tier. **It does not re-implement env-create -- it reuses the existing -paths**, so anyone touching those paths must understand the cascade re-entry: +:provider_cascade`, just after `:run_entry_smoke` returns) re-attempts the dependency phase under +the next provider tier. **It does not re-implement env-create -- it reuses the existing paths**, +so anyone touching those paths must understand the cascade re-entry: - `uv -> conda`: sets `HP_ENV_MODE=conda`, clears `HP_UV_PROVIDING_PYTHON`, sets - `ENV_PATH=%MINICONDA_ROOT%\envs\%ENVNAME%`, then `goto :try_conda_create`. Because uv-first - runs skipped Miniconda, `:cascade_acquire_conda` first downloads+installs it on demand - (mirroring the normal acquisition at lines ~423-432; `MINICONDA_ROOT`/`CONDA_MAIN`/`CONDA_ALT` - are already set near line 410 even in uv-first runs, so `:select_conda_bat` / `:try_conda_install` - work). `:try_conda_create` ends with `goto :after_env_mode_selection`, which re-runs dep - install + build. -- `conda -> embed` (reordered; was `conda -> venv` before the REQ-009 provider-chain reorder): - `call :try_embed_fallback` (sets `HP_ENV_MODE=embed`), then `goto :after_env_mode_selection`. - Suppressed when `HP_FORCE_CONDA_ONLY=1`. No `HP_OFFLINE_MODE`/consent gate of its own (mirrors - venv's zero-friction treatment, not system's). -- `embed -> venv` (new edge): `call :try_venv_fallback` (sets `HP_ENV_MODE=venv`), then - `goto :after_env_mode_selection`. -- `venv -> system`: `call :try_system_fallback` (sets `HP_ENV_MODE=system`), then - `goto :after_env_mode_selection`. **Reached in any run** -- the only gate is the REQ-014 - consent prompt inside `:try_system_fallback` (no env flag; `HP_ALLOW_SYSTEM_FALLBACK` is - deprecated/ignored). In CI the consent gate auto-declines (`HP_CI_LANE`, or an explicit - `HP_TEST_SYSCON_ANSWER=N`), so the cascade logs `cascading provider venv to system`, the gate - declines, and `:cascade_system_unavailable` keeps the current build -- the cascade stops at - system in CI without entering it. `HP_FORCE_CONDA_ONLY=1` suppresses embed/venv/system upstream - (the cascade never leaves `:cascade_from_conda`). System has no cascade target of its own (no - `:cascade_from_system` label/guard exists -- deleted when this reorder made system terminal - instead of embed; a re-entry with `HP_ENV_MODE=system` falls straight through - `:provider_cascade`'s dispatch table to the "tiers exhausted" catch-all). + `ENV_PATH=%MINICONDA_ROOT%\envs\%ENVNAME%`, `goto :try_conda_create`. Because uv-first runs + skipped Miniconda, `:cascade_acquire_conda` downloads+installs it on demand + (`MINICONDA_ROOT`/`CONDA_MAIN`/`CONDA_ALT` are already set near line 410 even in uv-first runs). + `:try_conda_create` ends with `goto :after_env_mode_selection`, re-running dep install + build. +- `conda -> embed` (reordered from `conda -> venv`): `call :try_embed_fallback`, then + `goto :after_env_mode_selection`. Suppressed when `HP_FORCE_CONDA_ONLY=1`. No + `HP_OFFLINE_MODE`/consent gate of its own (mirrors venv's zero-friction treatment, not system's). +- `embed -> venv` (new edge): `call :try_venv_fallback`, then `goto :after_env_mode_selection`. +- `venv -> system`: `call :try_system_fallback`, then `goto :after_env_mode_selection`. **Reached + in any run** -- the only gate is the REQ-014 consent prompt inside `:try_system_fallback` (no env + flag; `HP_ALLOW_SYSTEM_FALLBACK` is deprecated/ignored). In CI the gate auto-declines + (`HP_CI_LANE`, or explicit `HP_TEST_SYSCON_ANSWER=N`), so the cascade stops at system without + entering it. `HP_FORCE_CONDA_ONLY=1` suppresses embed/venv/system upstream. System has no + cascade target of its own (`:cascade_from_system` was deleted when this reorder made system + terminal instead of embed; a re-entry with `HP_ENV_MODE=system` falls straight through to the + "tiers exhausted" catch-all). **No-loop guarantee (touch one, understand all):** each tier is marked `HP_CASCADE_TRIED_` the first time it is used as a cascade source, and `HP_ENV_MODE` only ever advances @@ -682,233 +524,191 @@ phrases (`[REPAIR] missing modules detected`) and EXE success, both of which wor ### autopep723 discovery merge (REQ-005.12, Tier 1) sits inside the pipreqs diff-computation block -`:after_pipreqs_run` (`run_setup.bat` ~line 1258) is the shared fallthrough every pipreqs -code path funnels to (success/skip/failure all `goto` here -- see the `if -"%HP_PIPREQS_PHASE_RESULT%"=="ok" (...) else (...)` dispatch right at the top of the label). The -new Tier 1 block (docs/plan-autopep723-two-tier.md) is inserted immediately after the REQ-005.5 -`fc`-based diff computation and its `[INFO] REQ-005.5: dependency source diff computed` log line, -and immediately before the dep-check fast-path reset (`set "HP_DEP_SKIP="`) -- **any future edit -to either neighbor must re-verify this block still sits between them**, since the block's own -correctness depends on `requirements.txt` already being finalized by pipreqs (or copied from -`requirements.auto.txt`) by the time it runs, and on running strictly before `HP_DEP_SKIP`/ -`HP_DEP_RESULT`/`HP_UV_INSTALL_OK` are reset for the dep-check fast path below it. - -**v1-scoped to `HP_ENV_MODE=uv` only**, matching `plan-pep723-writeback.md`'s own v1 scope -decision -- the block computes `HP_UVX_EXE` unconditionally (a cheap string substitution, -`%HP_UV_EXE:uv.exe=uvx.exe%`) but only ever invokes it when `HP_ENV_MODE` is `uv` AND `HP_ENTRY` -is defined AND the derived `uvx.exe` path actually exists on disk -- the last check makes the -whole block a silent no-op under conda/venv/system/embed modes (where `HP_UV_EXE` is empty or -undefined, so `HP_UVX_EXE` is empty too) with no separate mode-gate needed beyond the `HP_ENV_MODE` -check itself. **`HP_UVX_EXE` is derived from `HP_UV_EXE`, not from `HP_UV_BIN` directly** -- -this matters for the `PVW_UV_EXE` super-user override (`run_setup.bat` ~line 449): when that -override is set, `HP_UV_BIN` never gets a `uvx.exe` extracted into it at all (the whole download -step is skipped), so deriving from `HP_UV_BIN` directly would silently break Tier 1 under that -override. Deriving from `HP_UV_EXE` via substring substitution works for both the normal -downloaded-zip case and the override case, since official `uv` distributions always ship -`uv.exe`/`uvx.exe` as sibling files in the same directory. - -**Writes to `requirements.txt`, not `requirements.auto.txt`** -- a deliberate merge-target -decision (see the plan doc's own "Merge target decision" section for the full trace of why this -is sufficient): the unconditional pip gap-fill step later in `:after_env_mode_selection` -(`%HP_PY% -m pip install -r requirements.txt`) reads the merged file regardless of whether the -dep-check fast path (`HP_DEP_SKIP`, which reads `requirements.auto.txt` instead) fires or not, so -anything Tier 1 adds is installed either way. The known, accepted trade-off: on a repeat run -where the dep-check fast path fires, an autopep-only-discovered package always goes through pip -rather than conda, mirroring this repo's existing "pip gap-fill safety net" pattern rather than -introducing a new failure mode. - -**Never gates the lane.** The merge helper (`tools/autopep_merge.py`, embedded as -`HP_AUTOPEP_MERGE`) always exits 0 and is purely additive -- it never removes or reorders -existing `requirements.txt` content, and a missing/empty `requirements.autopep.txt` (autopep723 -failed, found nothing, or the block was skipped entirely) is a silent no-op. The one `[WARN]` log -branch (`autopep723 merge helper failed`) is defensive only; nothing in the merge helper's own -design can produce a nonzero exit under normal operation. +`:after_pipreqs_run` (`run_setup.bat` ~line 1258) is the shared fallthrough every pipreqs code +path funnels to (success/skip/failure all `goto` here). The Tier 1 block +(docs/plan-autopep723-two-tier.md) is inserted immediately after the REQ-005.5 `fc`-based diff +computation's `[INFO] REQ-005.5: dependency source diff computed` log line, and immediately before +the dep-check fast-path reset (`set "HP_DEP_SKIP="`) -- **re-verify this block still sits between +those two neighbors on any future edit**: it depends on `requirements.txt` already being +finalized by pipreqs (or copied from `requirements.auto.txt`), and on running strictly before +`HP_DEP_SKIP`/`HP_DEP_RESULT`/`HP_UV_INSTALL_OK` are reset for the dep-check fast path below it. + +**v1-scoped to `HP_ENV_MODE=uv` only.** Computes `HP_UVX_EXE` unconditionally (cheap string +substitution, `%HP_UV_EXE:uv.exe=uvx.exe%`) but only invokes it when `HP_ENV_MODE` is `uv` AND +`HP_ENTRY` is defined AND the derived `uvx.exe` path exists on disk -- the last check makes the +block a silent no-op under conda/venv/system/embed (where `HP_UV_EXE` is empty). **`HP_UVX_EXE` is +derived from `HP_UV_EXE`, not `HP_UV_BIN` directly** -- matters for the `PVW_UV_EXE` override +(~line 449): when set, `HP_UV_BIN` never gets a `uvx.exe` extracted at all, so deriving from +`HP_UV_BIN` would silently break Tier 1 under that override. + +**Writes to `requirements.txt`, not `requirements.auto.txt`**: the unconditional pip gap-fill step +later (`%HP_PY% -m pip install -r requirements.txt`) reads the merged file regardless of whether +the dep-check fast path fires, so anything Tier 1 adds is installed either way. Accepted +trade-off: on a repeat run where the fast path fires, an autopep-only-discovered package always +goes through pip rather than conda (mirrors the existing "pip gap-fill safety net" pattern). + +**Never gates the lane, in practice.** The merge helper (`tools/autopep_merge.py`/ +`HP_AUTOPEP_MERGE`) is purely additive -- never removes/reorders existing `requirements.txt` +content, and a missing/empty `requirements.autopep.txt` is a silent no-op. Every normal/expected +path (no-op, successful merge) returns 0. **Correction: the module's own "never raises, always +exits 0" docstring claim is not literally true** -- `existing_names()`'s `open()` and `main()`'s +own trailing-newline/append `open()` calls are NOT wrapped in `try/except OSError` (unlike +`extract_autopep_deps()`, which is), so a genuine OSError there (permission denied, disk full, a +TOCTOU race between `os.path.exists()` and `open()`) propagates uncaught and exits nonzero via +Python's default uncaught-exception handling. `run_setup.bat` never treats this as fatal either +way -- the pipreqs-derived `requirements.txt` from earlier in the same flow is already on disk +and usable regardless of whether this merge step completes, so the bootstrap simply continues +with pipreqs-only results on that rare failure. ### HP_PVW_KNOWN_IDEMPOTENT execute-mode discovery (REQ-005.13, Tier 2) hooks in earlier than Tier 1 -`:pvw_known_idempotent_run` (`run_setup.bat` ~line 3289) is called from a gate right after -`:determine_entry` returns (~line 980), BEFORE the pyproject.toml/PEP 723 header/pipreqs block -even begins -- earlier than Tier 1's own insertion at `:after_pipreqs_run`. This is deliberate: -Tier 2's whole premise is "skip static discovery, use execution instead," so it must run before -any static-analysis-based dependency source gets a chance to populate `requirements.txt` first. -It does NOT need its own PEP-723-awareness, though -- `uvx autopep723 ` already respects -an existing header on its own (uses declared deps to set up the run, only surfaces something new -via a real `ModuleNotFoundError` at runtime), so Tier 2 inherits that correct behavior for free. - -**`HP_UVX_EXE` is computed a second time here, independently of Tier 1's own computation.** -Both insertion points derive it the same way (`%HP_UV_EXE:uv.exe=uvx.exe%`), but since Tier 2's -gate is earlier in the file than Tier 1's, it cannot reuse Tier 1's copy of the variable -- this -is intentional duplication (a cheap string substitution), not a bug to consolidate. - -**The real design wrinkle this hook point has to handle: `uv add --script` only updates the PEP -723 header, not `requirements.txt`.** A naive "run, persist, continue" implementation would leave -`requirements.txt` empty, since everything downstream of this point (`:after_pipreqs_run`'s own -dep-check fast path, `~prep_requirements.py`'s heuristic augmentation, the actual install step) -all operate on `requirements.txt`, never the header directly. Fixed by reusing the ALREADY-EXISTING -`:extract_pep723_requirements` subroutine (same one the pre-existing-header case at ~line 1017 -already uses) to re-extract the just-updated header straight into `requirements.txt` after a -successful Tier 2 run -- no second requirements.txt-writing mechanism was added. - -**Deliberately does NOT set `HP_SKIP_PIPREQS`.** Unlike the test file's own isolation technique -(`selfapps_pvw_idempotent.ps1` sets it for test purposes only), production Tier 2 code leaves -pipreqs free to run normally afterward -- it's additive layering, not a replacement: Tier 2 -anchors `requirements.txt` with what execution-based discovery found, and pipreqs/Tier 1's own -`autopep723 check` merge (REQ-005.12, which runs later at `:after_pipreqs_run`) still get their -normal chance to catch anything a single execution path didn't exercise (e.g. a conditionally -imported module whose branch wasn't hit during this particular run). This mirrors Tier 1's own -"augment, never replace" philosophy rather than introducing a new one. - -**Only stderr is redirected when the batch caller captures the helper's own result marker, -never stdout.** `tools/pvw_known_idempotent.py`'s `run_script()` deliberately leaves the child -`uvx autopep723 ` process's stdio fully inherited (no `capture_output`) so the user's own -script output prints live to the console, exactly like a normal `python entry.py` run -- this is -the entire point of the "execute-mode" framing. Because of that, the helper's own one-line -`RAN:` / `ERROR:` result marker is printed to **stderr**, not stdout (see the -helper's own module docstring) -- if a future edit ever redirects the batch call's stdout instead -of stderr to capture that marker, it would silently swallow the user's live script output into a -throwaway result file instead of showing it on the console. `tests/selfapps_pvw_idempotent.ps1` -guards against this regression directly: it asserts the stub app's own `print()` output appears -in the bootstrap log itself, not just that the run "succeeded." - -**`run_script()`'s live execution is bounded by a 120s timeout, found missing via a bug-hunt -pass and fixed.** This call genuinely runs the user's entry script (not a smoke test) as the -whole point of execute-mode discovery -- but unlike `discover_dep_names()`/`persist()` in the -same file (60s/120s timeouts respectively), it originally had none at all. A GUI-mainloop app or -a script that blocks on `input()` -- both completely ordinary Python programs -- would hang this -call, and therefore the entire bootstrap, forever with zero feedback, since this runs before any -build/verification phase and nothing downstream would ever get a chance to run. This is distinct -from the "never kill the user's real run" principle covering `:run_failfast_probe`'s later -verification runs (see "Fail-fast probe (Slice 2b-C)" below) -- THAT principle protects the run -that produces the user's actual deliverable output; this call is a throwaway discovery pass that -happens twice more anyway (the real, persistent-environment run comes later), so bounding it does -not cost the user anything a real run needs. On timeout, `run_script()` returns `1` (routing -through `main()`'s existing "other nonzero" best-effort-fillin-and-retry branch, same as any -other non-2 failure) rather than raising -- `tests/test_pvw_known_idempotent.py`'s `RunScript` -class asserts both that a timeout is set and that `subprocess.TimeoutExpired` is caught cleanly. +`:pvw_known_idempotent_run` (~line 3289) is called from a gate right after `:determine_entry` +returns (~line 980), BEFORE the pyproject.toml/PEP 723 header/pipreqs block even begins -- +earlier than Tier 1's own `:after_pipreqs_run` insertion, deliberately: Tier 2's premise is "skip +static discovery, use execution instead," so it must run before any static source populates +`requirements.txt`. Needs no PEP-723-awareness of its own -- `uvx autopep723 ` already +respects an existing header (uses declared deps to set up the run, only surfaces something new via +a real `ModuleNotFoundError` at runtime). + +**`HP_UVX_EXE` is computed a second time here, independently of Tier 1's copy** -- Tier 2's gate +is earlier in the file, so it cannot reuse Tier 1's variable (intentional duplication of a cheap +string substitution, not a bug to consolidate). + +**Design wrinkle: `uv add --script` only updates the PEP 723 header, not `requirements.txt`.** A +naive "run, persist, continue" would leave `requirements.txt` empty, since everything downstream +operates on that file, never the header directly. Fixed by reusing the already-existing +`:extract_pep723_requirements` subroutine (same one the pre-existing-header case uses) to +re-extract the just-updated header into `requirements.txt` after a successful Tier 2 run. + +**Deliberately does NOT set `HP_SKIP_PIPREQS`** (unlike `selfapps_pvw_idempotent.ps1`'s own +test-only isolation) -- production Tier 2 leaves pipreqs free to run normally afterward: additive +layering, not replacement. Tier 2 anchors `requirements.txt` with what execution-based discovery +found; pipreqs/Tier 1's own later merge still gets its normal chance to catch anything a single +execution path didn't exercise (e.g. a conditionally imported module on an unhit branch). + +**Only stderr is redirected when the batch caller captures the helper's result marker, never +stdout.** `tools/pvw_known_idempotent.py`'s `run_script()` leaves the child `uvx autopep723 +` process's stdio fully inherited (no `capture_output`) so the user's script output prints +live -- the entire point of "execute-mode." Its own `RAN:`/`ERROR:` marker is +therefore printed to **stderr**, not stdout -- redirecting the batch call's stdout instead would +silently swallow the user's live output into a throwaway file. `tests/selfapps_pvw_idempotent.ps1` +guards this directly: asserts the stub app's `print()` output appears in the bootstrap log, not +just that the run "succeeded." + +**`run_script()`'s live execution is bounded by a 120s timeout, found missing via a bug-hunt and +fixed.** This genuinely runs the user's entry script (not a smoke test) -- unlike +`discover_dep_names()`/`persist()` in the same file (60s/120s timeouts), it originally had none. A +GUI-mainloop app or a script blocking on `input()` would hang this call, and therefore the whole +bootstrap, forever, since this runs before any build/verification phase. Distinct from the "never +kill the user's real run" principle covering `:run_failfast_probe`'s later verification runs (see +"Fail-fast probe" below) -- that protects the run producing the actual deliverable; this is a +throwaway discovery pass that happens twice more anyway. On timeout, `run_script()` returns `1` +(routes through `main()`'s existing "other nonzero" retry branch) rather than raising -- +`tests/test_pvw_known_idempotent.py`'s `RunScript` class asserts the timeout and the clean +`subprocess.TimeoutExpired` catch. **Double-execution under this flag is intentional, not a REQ-018 gap.** The script runs once here -(via `uvx`, ephemeral tool venv, for discovery) and again later during the normal PyInstaller EXE -build's smoke-test verification (the real, persistent environment, producing the actual -deliverable). `HP_PVW_KNOWN_IDEMPOTENT`'s own name is the user's explicit, self-declared consent -to exactly this -- REQ-019's "flags only suppress, or add an alternate opt-in behavior, never gate -the default" already covers it; no additional REQ-018 carve-out was needed. +(ephemeral `uvx` venv, discovery) and again later during the normal PyInstaller EXE build's +smoke-test verification (real, persistent environment, producing the deliverable). +`HP_PVW_KNOWN_IDEMPOTENT`'s own name is the user's explicit consent to this -- REQ-019's "flags +only suppress or add an opt-in behavior, never gate the default" already covers it. -**Never gates the lane.** Any nonzero outcome (the run itself failing even after its one retry, -the helper payload failing to write, etc.) is logged as a `[WARN]` and the subroutine returns 0 -unconditionally -- the Default Path (pyproject.toml/PEP 723 header/pipreqs/Tier 1, all still to -come) picks up exactly as if `HP_PVW_KNOWN_IDEMPOTENT` had never been set. +**Never gates the lane.** Any nonzero outcome is logged `[WARN]` and the subroutine returns 0 +unconditionally -- the Default Path picks up exactly as if the flag had never been set. ### PEP 723 write-back (REQ-005.11) touches two hook points and the warnfix/lock flow -`:pep723_writeback` (new subroutine, called from two sites) is deliberately narrow -- -v1-scoped to `HP_ENV_MODE=uv` only -- but both call sites sit inside code this section and the -one above already document, so a future change to either must re-check this feature too: - -- **Fresh-install trigger, at `:lock_done`.** This label is the shared fallthrough for BOTH - the conda and uv dependency-install paths (see "Provider cascade execution re-enters - env-create" above for the general `:after_env_mode_selection` re-entrancy this label already - has to handle). `call :pep723_writeback fresh` is placed immediately after `:lock_done`, - before the pyvisa/visa detection block -- the v1 scope gate (`if not "%HP_ENV_MODE%"=="uv" - exit /b 0`, first line of the subroutine) makes this a no-op on the conda path without an - extra guard at the call site. +`:pep723_writeback` (called from two sites) is deliberately narrow -- v1-scoped to +`HP_ENV_MODE=uv` only -- but both call sites sit inside code this section and the one above +already document, so a future change to either must re-check this feature too: + +- **Fresh-install trigger, at `:lock_done`** -- the shared fallthrough for both conda/uv + dependency-install paths (see "Provider cascade execution re-enters env-create" above for the + `:after_env_mode_selection` re-entrancy this label already handles). `call :pep723_writeback + fresh` sits immediately after `:lock_done`, before the pyvisa/visa detection block -- the v1 + scope gate (`if not "%HP_ENV_MODE%"=="uv" exit /b 0`, first line) makes this a no-op on conda + without an extra call-site guard. - **Warnfix trigger, inside the warnfix repair block.** `call :pep723_writeback warnfix` sits between the `[REPAIR] rebuild complete after warnfix.` log line and the `:warnfix_cascade_detect` - call documented above -- `~missing_modules.txt` and `~warnfix_repair_failed.flag` are both - still on disk at that exact point (both are deleted a few lines later, right after - `:warnfix_cascade_detect` returns), so this is the only safe window to read them. Because this - call sits inside the same `if defined HP_WARNFIX_NEEDED ( ... )` parenthesized block as the - `for /f` loops that populate those two files, the subroutine call itself is safe from the - parse-time-`%VAR%`-expansion trap (`docs/agent-lessons-learned.md` "Provider-cascade dispatch - is goto-based on purpose") only because it passes a literal argument (`warnfix`) and reads - `~warnfix_repair_failed.flag`/`~missing_modules.txt` via runtime `if exist` checks inside its - OWN separate `call` frame, not via `%VAR%` substitution in the outer block. + call -- `~missing_modules.txt`/`~warnfix_repair_failed.flag` are both still on disk at that + exact point (deleted a few lines later). This call sits inside the same + `if defined HP_WARNFIX_NEEDED ( ... )` block as the `for /f` loops that populate those files, but + is safe from the parse-time-`%VAR%`-expansion trap (see `docs/agent-lessons-learned.md` + "Provider-cascade dispatch is goto-based on purpose") only because it passes a literal argument + (`warnfix`) and reads the two files via runtime `if exist` checks inside its OWN call frame, not + via `%VAR%` substitution in the outer block. - **`HP_UV_INSTALL_OK` reset lives in `:after_env_mode_selection`'s existing reset block**, - alongside `HP_DEP_SKIP`/`HP_DEP_RESULT` (same re-entrancy reasoning as those two: a REQ-009 - provider-cascade retry re-enters this label from scratch, and a stale `HP_UV_INSTALL_OK=1` - from a previous, now-abandoned uv attempt must never silently satisfy the fresh-trigger - "confirmed installed" gate on a later, unrelated cascade tier). It is set to `1` in exactly - two places inside the uv dependency-install branch: the genuine-install-succeeded `else` - branch of the `uv pip install -r requirements.txt` call, and the `HP_DEP_SKIP`-short-circuited - branch (already-satisfied-by-lock is still a confirmed-installed state, not a failure). -- **Packages-file staging is a plain `copy`, not a re-derivation.** The subroutine never - re-resolves what was installed -- it copies whichever source file the trigger implies - (`requirements.txt` for fresh, `~missing_modules.txt` for warnfix) to `~pep723_pkgs.txt` and - hands that straight to the embedded helper. This is the "all-or-nothing per round" design - from `docs/plan-pep723-writeback.md` Part 2.0 point 3: neither trigger point can know which - subset of N packages in a single failed bulk install actually succeeded, so a failed round - (uv install errorlevel 1, or a `~warnfix_repair_failed.flag` present) skips the write-back - entirely rather than guessing a partial set. -- **v1 does not touch venv/conda/embed/system modes at all.** A future extension of this - feature to another provider (if ever undertaken) would need a mechanism analogous to - `HP_UV_INSTALL_OK` for whichever provider's own install branch, plus a corresponding v1 - scope-gate relaxation in `:pep723_writeback`'s first line -- currently that gate is the - single point controlling the feature's entire footprint. + alongside `HP_DEP_SKIP`/`HP_DEP_RESULT` (same re-entrancy reasoning: a stale + `HP_UV_INSTALL_OK=1` from an abandoned uv attempt must never silently satisfy a later cascade + tier's fresh-trigger gate). Set to `1` in exactly two places in the uv install branch: the + genuine-install-succeeded `else` branch, and the `HP_DEP_SKIP`-short-circuited branch + (already-satisfied-by-lock is a confirmed-installed state, not a failure). +- **Packages-file staging is a plain `copy`, not a re-derivation.** Copies whichever source file + the trigger implies (`requirements.txt` for fresh, `~missing_modules.txt` for warnfix) to + `~pep723_pkgs.txt` and hands it to the embedded helper. "All-or-nothing per round" design + (`docs/plan-pep723-writeback.md` Part 2.0 point 3): neither trigger point can know which subset + of a failed bulk install actually succeeded, so a failed round skips write-back entirely rather + than guessing a partial set. +- **v1 does not touch venv/conda/embed/system modes at all.** A future extension would need a + mechanism analogous to `HP_UV_INSTALL_OK` per provider, plus a matching v1 scope-gate + relaxation in `:pep723_writeback`'s first line -- currently the single point controlling the + feature's entire footprint. ### dep-check + uv mode lock file interconnection -`~environment.lock.txt` is the dep-check cache key. In conda mode it is written via -`conda list --export`. In uv mode it is written by copying `~dependency_installed.txt` -(run_setup.bat lines 1141-1145). This ensures `selfapps_depcheck.ps1` works correctly -in uv-first lanes: the lock file exists after run 1, and dep-check on run 2 correctly -finds all packages already in the lock and emits the skip log line. - -`dep_check.py` is run for BOTH `HP_ENV_MODE=conda` and `HP_ENV_MODE=uv` (line 1048: -`if not "%HP_ENV_MODE%"=="conda" if not "%HP_ENV_MODE%"=="uv" goto :dep_check_done`). -For venv and system modes, dep_check is skipped entirely (no lock written, no skip check). +`~environment.lock.txt` is the dep-check cache key: conda mode writes it via `conda list +--export`; uv mode writes it by copying `~dependency_installed.txt` (lines 1141-1145). This is +why `selfapps_depcheck.ps1` works in uv-first lanes -- the lock exists after run 1, so dep-check +on run 2 finds all packages already in it and emits the skip log line. -`HP_DEP_SKIP` is honored in uv mode too (line 1108: `if not defined HP_DEP_SKIP` guards -the `uv pip install` call), so second-run dep-check correctly skips pip install as well. +`dep_check.py` runs for BOTH `HP_ENV_MODE=conda` and `HP_ENV_MODE=uv` (line 1048: `if not +"%HP_ENV_MODE%"=="conda" if not "%HP_ENV_MODE%"=="uv" goto :dep_check_done`) -- skipped entirely +for venv/system (no lock written, no skip check). `HP_DEP_SKIP` is honored in uv mode too (line +1108 guards the `uv pip install` call), so second-run dep-check skips pip install as well. ### HP_TEST_FORCE_UV_FAIL and HP_TEST_CORRUPT_UV interaction -`HP_TEST_FORCE_UV_FAIL=1` fires at `run_setup.bat` line 296 (BEFORE the cached-uv check -at line 302 where `HP_TEST_CORRUPT_UV` fires). If both are set simultaneously, the -FORCE_UV_FAIL gate fires first and the CORRUPT_UV test never reaches its trigger. - -Fix (applied in `tests/selftest.ps1`): the corrupt-uv sub-bootstrap saves/clears/restores -`HP_TEST_FORCE_UV_FAIL` so that the corrupt-uv branch is correctly exercised in all lanes. +`HP_TEST_FORCE_UV_FAIL=1` fires at line 296, BEFORE the cached-uv check at line 302 where +`HP_TEST_CORRUPT_UV` fires -- if both are set, FORCE_UV_FAIL wins and CORRUPT_UV never triggers. +Fix (`tests/selftest.ps1`): the corrupt-uv sub-bootstrap saves/clears/restores +`HP_TEST_FORCE_UV_FAIL` so the corrupt-uv branch is exercised in all lanes. ### Malformed pyproject.toml + uv venv failure (uv-first lanes) -`uv venv` reads `pyproject.toml` for `[project].requires-python` even without `--python`. -When pyproject.toml is malformed TOML, `uv venv` exits non-zero. In the uv-first real lane -(no conda installed), this cascades: `:uv_venv_fail` falls to `:try_conda_create`, but -`CONDA_BAT` is empty, so conda create also fails, and the bootstrap exits non-zero. - -**Symptom**: `self.pyproject.malformed` fails in real/cache lanes (uv-first). -**Root cause**: `uv venv` runs in the project directory and hits the malformed TOML before -`HP_PYPROJ_DEPS` gets a chance to detect and warn about it. -**Fix** (in `:uv_venv_fail`): when `HP_UV_PROVIDING_PYTHON=1`, retry via -`uv run --no-project python -m venv .uv_env` which bypasses project discovery entirely. -On success, go to `:uv_venv_ready`. The `HP_PYPROJ_DEPS` path (line ~712) then naturally -detects the malformed TOML and emits `[WARN] pyproject.toml TOML parse error; falling back.` - -**Note**: `UV_NO_CONFIG=1` does NOT help -- uv's own docs say: "Note that if a pyproject.toml -file is present, uv will still use the [project] metadata (e.g., requires-python) to guide -dependency resolution." Only `--no-project` truly bypasses pyproject.toml discovery. - -**Note**: `~detect_python.py` reads pyproject.toml via REGEX (not TOML parser), so it exits 0 -even on malformed TOML (just returns empty string). That's why `HP_UV_PROVIDING_PYTHON=1` is -set correctly, and the venv creation step is the first point of failure. - -**Malformed TOML regex fallback detail** (the helper must not assume `tomllib`; see the -embedded-helper Python baseline in `docs/agent-lessons-learned.md`): -The regex must detect `[project` missing the closing `]` even without tomllib. The fix: -`re.search(r'^\[project\s*$', txt, re.MULTILINE)` -- matches `[project` at end of line -(with optional whitespace/CRLF). This exits 2 so the caller emits WARN. No false positives -for sub-tables like `[project.urls]` because `\s*$` requires end of line after `project`. - -**Test assertion** (`tests/selftest.ps1` lines 599-643, all lanes, no HP_FORCE_CONDA_ONLY): -- Asserts: `[WARN] pyproject.toml TOML parse error` in log AND `exitCode == 0` -- Row: `self.pyproject.malformed` -- In conda-full lane: malformed TOML is detected by HP_PYPROJ_DEPS (conda create doesn't read TOML) -- In uv-first lane: the `:uv_venv_fail` retry path allows the bootstrap to continue so HP_PYPROJ_DEPS runs +`uv venv` reads `pyproject.toml` for `[project].requires-python` even without `--python`; a +malformed TOML makes it exit non-zero. In the uv-first real lane (no conda installed), this +cascades: `:uv_venv_fail` falls to `:try_conda_create`, `CONDA_BAT` is empty, conda create also +fails, bootstrap exits non-zero. + +**Symptom**: `self.pyproject.malformed` fails in real/cache lanes. **Root cause**: `uv venv` runs +in the project directory and hits the malformed TOML before `HP_PYPROJ_DEPS` gets a chance to +detect and warn. **Fix** (`:uv_venv_fail`): when `HP_UV_PROVIDING_PYTHON=1`, retry via `uv run +--no-project python -m venv .uv_env`, which bypasses project discovery entirely; on success go to +`:uv_venv_ready`, and `HP_PYPROJ_DEPS` then naturally detects the malformed TOML and emits +`[WARN] pyproject.toml TOML parse error; falling back.` + +**`UV_NO_CONFIG=1` does NOT help** -- uv's own docs: pyproject.toml `[project]` metadata (e.g. +`requires-python`) is still used to guide resolution regardless. Only `--no-project` truly +bypasses pyproject.toml discovery. **`~detect_python.py` reads pyproject.toml via REGEX** (not a +TOML parser), so it exits 0 even on malformed TOML (returns empty string) -- `HP_UV_PROVIDING_ +PYTHON=1` is set correctly, and venv creation is the actual first point of failure. + +**Malformed TOML regex fallback** (must not assume `tomllib`; see the embedded-helper Python +baseline in `docs/agent-lessons-learned.md`): `re.search(r'^\[project\s*$', txt, re.MULTILINE)` +matches `[project` missing its closing `]` at end of line (optional whitespace/CRLF), exits 2 so +the caller emits WARN. No false positives for sub-tables like `[project.urls]` (`\s*$` requires +end of line right after `project`). + +**Test assertion** (`tests/selftest.ps1` lines 599-643, all lanes, no HP_FORCE_CONDA_ONLY): row +`self.pyproject.malformed` asserts `[WARN] pyproject.toml TOML parse error` in log AND `exitCode +== 0`. In conda-full, malformed TOML is detected by HP_PYPROJ_DEPS (conda create doesn't read +TOML); in uv-first, the `:uv_venv_fail` retry lets the bootstrap continue so HP_PYPROJ_DEPS runs. ### HP_FORCE_CONDA_ONLY as a test-override pattern -Some tests that specifically test conda behavior SET `HP_FORCE_CONDA_ONLY=1` themselves, -rather than relying on the CI lane. These tests are self-contained and work in all lanes -(including uv-first lanes, where they trigger Miniconda download if not already installed): +Some tests that specifically test conda behavior SET `HP_FORCE_CONDA_ONLY=1` themselves rather +than relying on the CI lane -- self-contained, work in all lanes (triggering Miniconda download +in uv-first lanes if not already installed): | Test | Why it sets HP_FORCE_CONDA_ONLY=1 | |------|-----------------------------------| @@ -925,15 +725,12 @@ as a side effect but is testing something else -> skip=true in uv-first lanes. ### HP_UV_BIN locality: why offline sub-bootstrap tests work in all lanes -`HP_UV_BIN` is set to `%HP_SCRIPT_ROOT%~uv_bin` (run_setup.bat line 290), where `HP_SCRIPT_ROOT` -is the directory containing the bootstrapper, not a system temp or user-global path. This has -a critical consequence for sub-bootstrap tests: - -**When a test creates a fresh temp directory and copies run_setup.bat into it:** -- The sub-bootstrap's `HP_SCRIPT_ROOT` = the new temp dir -- `HP_UV_BIN` = `~selftest_foo\~uv_bin\` (empty, no uv.exe) -- `HP_OFFLINE_MODE=1` (set by the test) prevents re-downloading uv -- Result: uv is ALWAYS unavailable in the sub-bootstrap +`HP_UV_BIN` is set to `%HP_SCRIPT_ROOT%~uv_bin` (line 290), where `HP_SCRIPT_ROOT` is the +bootstrapper's own directory, not a system temp or user-global path. Consequence: when a test +creates a fresh temp directory and copies `run_setup.bat` into it, the sub-bootstrap's +`HP_SCRIPT_ROOT` is that new temp dir, so `HP_UV_BIN` = `~selftest_foo\~uv_bin\` (empty, no +uv.exe); combined with the test's own `HP_OFFLINE_MODE=1` (blocks re-download), uv is ALWAYS +unavailable in the sub-bootstrap. This makes the fallback chain tests in `selfapps_ux_hardening.ps1` work correctly in uv-first (real/cache) lanes even though those lanes normally use uv as the primary provider: @@ -959,10 +756,9 @@ not set it (confirmed by grepping batch-check.yml). ### selfapps_isolation.ps1: HP_CI_SKIP_ENV=1 bypasses all provider logic The three tests in `selfapps_isolation.ps1` (crossdir, sameDir, req010.pythonpath) run the -bootstrapper with `HP_CI_SKIP_ENV=1` inherited from the CI environment. This causes run_setup.bat -to jump to `:ci_skip_entry` (lines ~1090-1196) which uses system Python and bypasses the entire -provider selection (no uv, no conda, no venv decision). These tests are completely lane-agnostic -and safe in all lanes including uv-first. +bootstrapper with `HP_CI_SKIP_ENV=1` inherited from CI, jumping to `:ci_skip_entry` (~1090-1196) +which uses system Python and bypasses provider selection entirely -- lane-agnostic, safe in all +lanes including uv-first. ### selfapps_skiphooks.ps1: provider-independent, conda-full lane only @@ -1031,16 +827,20 @@ if ($env:HP_FORCE_CONDA_ONLY -ne '1') { ## Single-verification smoke model (REQ-018 2b-A.2) couples run_setup.bat to envsmoke/skiphooks -The bootstrapper runs the user's code for verification at most ONCE per invocation. Touching the +The bootstrapper runs the user's code for MANDATORY verification exactly ONCE per invocation +(the pre-build interpreter smoke and a second EXE smoke were merged into one). An accepted +post-execution checkpoint (see that section below) can still launch an OPTIONAL second run -- +"once per invocation" describes the automatic/default flow, not an absolute ceiling. Touching the smoke flow in `run_setup.bat` requires understanding the assertions in `tests/selfapps_envsmoke.ps1` and `tests/selfapps_skiphooks.ps1`, which are coupled by exact log strings and run artifacts: - **Removed:** the pre-build interpreter smoke (in `:run_entry_smoke`) and - `:try_entry_smoke_after_warnfix`. The EXE path no longer runs the app twice (interpreter then EXE). -- **EXE path = sole verification via the timed EXE smoke** (`:run_exe_smokerun`). To keep the - existing tests passing WITHOUT re-pointing them, the EXE smoke now emits the **same vocabulary** - the interpreter smoke used and **captures the EXE stdout/stderr** to the app root: + `:try_entry_smoke_after_warnfix` -- the EXE path no longer runs the app twice. +- **EXE path = primary (mandatory) verification via the timed EXE smoke** (`:run_exe_smokerun`). + To keep existing + tests passing without re-pointing them, it emits the **same vocabulary** the interpreter smoke + used and **captures the EXE stdout/stderr** to the app root: - `[INFO] Running entry script smoke test via packaged EXE.` -> matches envsmoke `$hasEntryRun` (`'Running entry script smoke test'`). - `[INFO] Entry smoke exit=%HP_EXE_EXIT%` at `:smokerun_ndjson` -> matches `$hasEntryExit` @@ -1085,697 +885,442 @@ strings and run artifacts: ## Fail-fast probe (Slice 2b-C): shared state machine for the two untimed launch points `:try_fast_exe` (cached EXE reuse) and `:verify_no_exe_interpreter` (no-EXE interpreter run) both -launch user code with NO timeout at all in CI/automation (unchanged, plain `cmd` redirect). For a -real interactive double-click user (`HP_INTERACTIVE_RUN` set -- see `:compute_interactive_run`, -mirrors `:pick_entry_interactive`'s `NOINPUT`/`HP_NONINTERACTIVE`/`HP_CI_LANE` signals, plus -`HP_TEST_FORCE_INTERACTIVE_PROBE=1` to force the branch under `HP_CI_LANE` for CI coverage), both -call the shared `:run_failfast_probe` subroutine instead, which launches via -`~failfast_probe.ps1` (`HP_FAILFAST_PROBE`, a base64 embedded helper emitted through the existing -`:emit_from_base64` mechanism -- NOT an inline `-Command` one-liner, deliberately: the two-stage -wait needs interpolated strings, and `.ps1` file content sidesteps every cmd.exe quote-nesting -hazard an inline `-Command "..."` string would hit here). The helper does `WaitForExit(HP_FAILFAST_PROBE_MS)` -(default 10000ms, distinct from the unrelated ~30s hard-kill cap used by `:run_exe_smokerun`/ -`:hidden_import_recover` -- that is a force-kill ceiling for the fresh-build verification run, the -ONLY run this bootstrapper ever kills; this probe window is purely a classification checkpoint, -never a ceiling) then, if the process is still running, a SECOND, UNBOUNDED `WaitForExit()` with no -`Kill()` call anywhere. +launch user code with NO timeout in CI/automation (unchanged, plain `cmd` redirect). For a real +interactive double-click user (`HP_INTERACTIVE_RUN` set -- see `:compute_interactive_run`, mirrors +`:pick_entry_interactive`'s non-interactivity signals, plus `HP_TEST_FORCE_INTERACTIVE_PROBE=1` to +force the branch under `HP_CI_LANE` for CI coverage), both call the shared `:run_failfast_probe` +instead, which launches via `~failfast_probe.ps1` (`HP_FAILFAST_PROBE`, an emitted `.ps1` file, not +inline `-Command` -- the two-stage wait needs interpolated strings, which sidesteps every cmd.exe +quote-nesting hazard an inline command would hit). The helper does +`WaitForExit(HP_FAILFAST_PROBE_MS)` (default 10000ms -- distinct from the unrelated ~30s hard-kill +cap used by `:run_exe_smokerun`/`:hidden_import_recover`, which force-kills the fresh-build +verification run, the ONLY run this bootstrapper ever kills; this probe window is purely a +classification checkpoint, never a ceiling), then, if still running, a SECOND, UNBOUNDED +`WaitForExit()` with no `Kill()` anywhere. **Touch either call site, must understand the other, plus the top-of-file success gate:** -- Both callers set `HP_PROBE_EXE` / `HP_PROBE_ARGS` (raw, UNQUOTED -- the helper quotes it via - `'"' + $rawArgs + '"'`, which only works correctly for a SINGLE path argument; do not repurpose - `HP_PROBE_ARGS` for a multi-token command line) / `HP_PROBE_CWD` before calling - `:run_failfast_probe `. **CWD is preserved per call site exactly as before this slice**: - `:try_fast_exe` runs from the app root (`%CD%`, no `pushd dist`) and `:verify_no_exe_interpreter` - also runs from the app root -- neither adopts `:run_exe_smokerun`'s `pushd dist` CWD (load-bearing - for `selfapps_exedata_fail`'s CWD-relative `config.json` check; see the paragraph above). If you - ever unify these CWDs, re-verify that xfail test. -- `:run_failfast_probe` always leaves `HP_SMOKE_RC` set to the true final exit code (whether the - process exited fast or only after the unbounded continuation) and `HP_PROBE_EXCEEDED` set (`1`) - iff the probe window was crossed. `:try_fast_exe`'s discard-and-rebuild block is gated on - `if not "%HP_SMOKE_RC%"=="0" if not defined HP_PROBE_EXCEEDED` -- once a process is classified - alive/healthy at the probe, a LATER non-zero exit is presumed to be the user's own program outcome - (not proof of a stale artifact) and the cached EXE is kept, never discarded. -- **The silent-success bug this closed:** the top-of-file fast-path caller (`run_setup.bat`, near - the very top, before provider selection) used to gate its `goto :success` shortcut on - `HP_FASTPATH_USED` alone, with no check of the run's outcome -- harmless before this slice because - any non-zero `HP_SMOKE_RC` always cleared `HP_FASTPATH_USED` too (inside `:try_fast_exe`'s old - unconditional discard). Once the probe's "don't discard past the probe window" rule could leave - `HP_FASTPATH_USED` set through a real later failure, that same shortcut would have silently - reported full bootstrap success while hiding the failure. Fixed by computing +- Both callers set `HP_PROBE_EXE`/`HP_PROBE_ARGS`/`HP_PROBE_CWD` before calling `:run_failfast_probe + `. **`HP_PROBE_ARGS` is a complete, ready-to-use Windows Arguments string, per the + caller-owned quoting contract the Argv passthrough feature established (see below): the caller + quotes each token, and `~failfast_probe.ps1` assigns the string to `ProcessStartInfo.Arguments` + VERBATIM, adding no quotes of its own.** Do not wrap the complete string in another pair of + quotes -- that collapses multiple arguments into one token. **CWD is preserved per call site**: both `:try_fast_exe` and + `:verify_no_exe_interpreter` run from the app root (no `pushd dist`), unlike + `:run_exe_smokerun`'s `pushd dist` (load-bearing for `selfapps_exedata_fail`'s CWD-relative + `config.json` xfail check -- re-verify that test if these CWDs are ever unified). +- `:run_failfast_probe` always leaves `HP_SMOKE_RC` set to the true final exit code and + `HP_PROBE_EXCEEDED` set (`1`) iff the probe window was crossed. `:try_fast_exe`'s + discard-and-rebuild block is gated on `if not "%HP_SMOKE_RC%"=="0" if not defined + HP_PROBE_EXCEEDED` -- once a process is classified alive/healthy at the probe, a LATER non-zero + exit is presumed the user's own program outcome (not a stale artifact) and the cached EXE is + kept, never discarded. +- **Silent-success bug this closed:** the top-of-file fast-path caller used to gate its `goto + :success` shortcut on `HP_FASTPATH_USED` alone, with no outcome check -- harmless before this + slice (any non-zero `HP_SMOKE_RC` always cleared `HP_FASTPATH_USED` too). Once the probe's + "don't discard past the window" rule could leave `HP_FASTPATH_USED` set through a real later + failure, that shortcut would silently report full success while hiding it. Fixed by computing `HP_FASTPATH_RUN_FAILED` (true only when `HP_SMOKE_RC` is DEFINED and non-"0" -- empty/undefined - `HP_SMOKE_RC` still means "no real failure observed," e.g. the REQ-012 - `HP_SKIP_EXE_SMOKERUN` skip-without-running case, which must still take the zero-friction path) - and branching the log message on it before `write_status`/`goto :success`; `HP_BOOTSTRAP_STATE` - stays `ok` either way (env/build genuinely succeeded; a runtime bug in the user's own code is not - something a rebuild could fix -- matches the "User-code exit-code semantics" item in - `CLAUDE.md`'s Active Backlog), but the console/log now always shows the true - `[STATUS] Run Status: ...` outcome first. This second call site was recomputed with the exact - same `HP_FASTPATH_RUN_FAILED` guard (see the next bullet). -- **Both `:try_fast_exe` call sites now carry this guard, not just the top-of-file one.** The - second call site (inside `:run_entry_smoke`'s build gate, `if defined HP_FASTPATH_USED (...)` - just before the PyInstaller build block) recomputes `HP_FASTPATH_RUN_FAILED` fresh as its own - top-level statement and branches the same way. It is normally unreachable with a real failure - today -- any first-call success or post-probe-failure outcome already took `goto :success` - before this point -- but a future provider-cascade re-entry (`:after_env_mode_selection`) could - reach it with `HP_FASTPATH_USED` still set from a probe-classified alive-then-failed run, and - this closes that gap defensively rather than leaving a second, unguarded copy of the same logic. -- **cmd.exe parse-time-expansion hazard, avoided via goto, not if/else, at both call sites.** An - earlier revision of this slice launched the process and read `set "HP_SMOKE_RC=%ERRORLEVEL%"` - (plus the immediate SUCCESS/FAILED branch) INSIDE the non-interactive `else ( ... )` clause of - the `if defined HP_INTERACTIVE_RUN (...) else (...)` dispatch. cmd.exe expands every `%VAR%` in - a parenthesized block ONCE, at parse time, using values from before the block started -- so - `%ERRORLEVEL%` (and every in-block `%HP_SMOKE_RC%` read) silently froze to whatever it was right - before the dispatch began (almost always `"0"`), meaning a genuinely broken cached EXE was NEVER - discarded in the legacy/CI branch. Both `:try_fast_exe` and `:verify_no_exe_interpreter` now use - `if defined HP_INTERACTIVE_RUN goto :