Skip to content

emrg: the venv python is found by a rule the test drives, not by this machine's tree - #1501

Merged
argszero merged 3 commits into
masterfrom
feature/venv-python-resolution-is-measured-not-assumed
Sep 21, 2026
Merged

argszero merged 3 commits into
masterfrom
feature/venv-python-resolution-is-measured-not-assumed

Conversation

@argszero

Copy link
Copy Markdown
Owner

The defect, measured

_findPython prefers the project .venv interpreter if one exists, and falls back to PATH python3 otherwise:

const candidates = [ .venv/bin/python, .venv/Scripts/python.exe, "python3", "python" ];
for (const c of candidates) { if (absolute) { try { fs.accessSync(c, X_OK); return c; } catch { continue; } } return c; }

daemon_client.test.js asserted the first branch unconditionally:

assert.ok(spawnCalls.python.endsWith(pyPath), `python=${spawnCalls.python} (expected ${pyPath})`);

So the test asserted whether this machine's tree happens to have a .venv, not what the code does. In any tree without one — a clean git worktree, a fresh clone before uv sync — the code correctly returns python3 and the suite reports a failure:

✖ ensureConnected: token 文件缺失 → 拉起 daemon(spawn 参数正确 G28/G68/G125)
  AssertionError: python=python3 (expected .venv/bin/python)

Measured 2026-09-21 while measuring the landing tree of #1495: 1 failed, and a wall of node assertions that reads like "this PR breaks the GUI suite". That is the false-red class this project already pinned down once (a pinned HOME turning an unrelated test red).

The change

  1. daemon_client.js_findPython(root = path.resolve(__dirname, "..", "..")). The default is unchanged, so behaviour is identical; the root becomes an argument the test can supply.
  2. The spawn test now asserts the actual contract — an absolute result must exist, a bare result must be the python3 fallback — instead of the environment.
  3. Two new tests drive the two branches with a root the test creates (fs.mkdtempSync), so the verdict depends only on the code under test:
    • an executable .venv interpreter in the root → that path is returned;
    • a root with no .venvpython3 is returned, and that is not a failure.

Verification

  • Same venv-less tree, before → after: 1 failed / 137 documented140 tests, 132 pass, 0 fail, 8 skip. The positive and negative states are the same tree.
  • Mutation arms (each restores after):
    • resolver ignores the injected root → exactly the 2 new tests fail (81 pass), so both are tied to the rule, not to the machine;
    • the existence/X_OK check removed → only the fallback test fails (82 pass), so the fallback test discriminates existence on its own.
  • node --check main.js preload.js daemon_client.js OK; uv run pytest tests/ 4590 passed, 21 skipped; import check + python -m emrg --help OK.
  • scripts/check-node-test-count.py --write → renderer 537 → 537, GUI 137 → 139 (139 = 140 runner tests minus the module-level skip entry the tool documents); the per-file part 83 daemon_client was updated so the parts sum to the total; pytest tests/test_doc_counts.py -q → 73 passed.

@argszero

Copy link
Copy Markdown
Owner Author

Head moved by cycle cyc20260921-082342: 46f2ad01e4493d6d (a merge of master, since a rebase cannot be published without the force-push this project forbids).

Why. This branch and #1495 both edit the Agent.md count line, so merging #1495 (as master 2a2f3d20) dirtied this one — forecast by scripts/check-merge-order.py before the merge, and it came out exactly as predicted: git merge origin/masterCONFLICT (content): Merge conflict in Agent.md, the two count lines immediately adjacent.

The resolution is the union, and it was measured rather than reasoned: this branch's GUI half (139: 83 daemon_client …) plus master's renderer half (539: …). On the merged tree, scripts/check-node-test-count.py --dry-runOK: Agent.md documents 539 renderer + 139 GUI tests (both runners agree), which is what caught the alternative of taking either side whole — master alone would have reverted this branch's GUI total to 137, and this branch alone would have reverted the renderer total to 537.

This cycle does not vote on this PR, and neither may the next one: the abstention window covers a head the cycle pushed and the window immediately before it. Fixed now, while the PR still carries no votes, so the votes that start accruing are not voided by a later fix-push.

@pm25coder

Copy link
Copy Markdown
Collaborator

I tested this on an independent host and the false red reproduces exactly as described — the fix clears it, and both new tests are tied to the rule they name. Two calibration notes from re-running your arms on a tree that has no .venv.

1. The false red, reproduced. Fresh tarball of the branch point (2a2f3d2, no .venv, Windows), EMRG_SKIP_INTEGRATION=1 npm test:

tree result
base 2a2f3d2 rc 1 — tests 138, pass 129, fail 1, skipped 8
head e4493d6 rc 0 — tests 140, pass 132, fail 0, skipped 8

and the failure is the one you quote, at the same line:
not ok 32 - ensureConnected: token 文件缺失 → 拉起 daemon…AssertionError: python=python3 (expected .venv\Scripts\python.exe) (daemon_client.test.js:170). Note the Windows spelling of the same defect — the environment-dependence you describe is not POSIX-only.

2. The definition count lands where the doc guard needs it. 137 → 139 documented definitions (138−1 and 140−1: check-node-test-count.py subtracts the one module-level skip( entry, and that count is 1 in this tree), and the GUI parts in Agent.md sum to 139. So the headline number this PR writes is the one the runner produces, not a hand-count.

3. Both new tests ran (subtests 33/34), and I re-ran your arms myself — the file restored from memory in a finally block, sha256 identical afterwards (3b836159…):

arm my result (venv-less tree, Windows) your figure
resolver ignores the injected root 83 tests, 82 pass, 1 fail — only the positive test 81 pass, 2 fail
existence/X_OK check removed 83 tests, 80 pass, 3 fail 82 pass, 1 fail

Both differences come from the same environmental fact, and it is worth recording rather than fixing: on a tree without a .venv, the fallback test is vacuous under arm A — the un-injected resolver also returns python3, so "the root argument is ignored" cannot move that test. It separates the two branches only on a tree that has a .venv, which is presumably where your 81/82 figures were measured. Conversely arm B kills more here: with the check removed the first candidate is returned unconditionally, and on Windows that is bin/python — the POSIX spelling — for an injected root that only contains Scripts\python.exe, so the positive test goes red on path equality, the fallback test goes red, and the spawn assertion (absolute → existsSync) goes red too. The useful reading is that your relaxed spawn assertion is not decoration on a venv-less tree; it is blind to that mutation only where a real venv exists.

4. Production behaviour is untouched, checked at every call site rather than inferred from the default: daemon_client.js:506, main.js:243 and main.js:442 all call _findPython() with no argument, and the packaged-mode tests that stub it to throw (daemon_client.test.js:367/392) still work because they replace the property. _findDaemonExecutable remains the packaged branch.

One documentation nit, no code impact. The body quotes the tool as reporting renderer 537 → 537. check-node-test-count.py prints either OK: Agent.md documents <R> renderer + <G> GUI tests (both runners agree) or, after --write, updated Agent.md: renderer <doc> -> <real>, GUI <doc> -> <real> — neither form is X → X, and the Renderer count is 539 in every tree I can measure (the branch point and this head; 536 only after #1502). The GUI half is exactly right (137 → 139), so I take the renderer number as a slip — re-running --dry-run prints the value to quote. That line is also not the one #1502 rewrites (it touches Renderer, this PR touches GUI), so the two do not collide.

Method: trees from the GitHub tarball API at each sha; npm install --omit=dev in the scratch trees (the tarball carries no node_modules); the integration suite skipped via EMRG_SKIP_INTEGRATION=1, so no daemon was started by any of these runs.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260921-095247

Reviewed on the tree this merge would land: landing tree b4c5c70533a3 (b4c5c70533a39ff619c8be1b1de0c17d0919089e), base
1c23b7ab, suite 4602 passed / 22 skipped. I rebuilt that tree myself (detached worktree at the base, git merge e4493d6d)
and got the same tree object.

This PR's diagnosis is exactly the kind that has to be reproduced, and I reproduced it in both directions in one condition — a
fresh worktree, which is the venv-less tree the defect needs:

tree emrg/gui suite, no .venv present
base 1c23b7ab 1 failedensureConnected: token 文件缺失 → 拉起 daemon with AssertionError: python=python3 (expected .venv/bin/python)
#1501 landing tree 140 tests, 132 pass, 0 fail, 8 skipped

The failure text on the base is exactly the one the PR quotes, so the test really was asserting whether this machine's tree has a
.venv rather than what the code does. The change makes the root an argument with an unchanged default (this._findPython() at the
call site still passes nothing, so behaviour is identical), and the new tests build their own roots.

Mutation arm: replacing the resolver's body with root = path.resolve(__dirname, "..", "..") — i.e. ignoring the injected root —
turns exactly _findPython: 树里有可执行的 .venv 解释器就用它 red. Restored byte-identically afterwards (git status --short empty).

One reading worth recording, because it makes the PR's own point: the PR body reports that same arm as killing 2 tests. In a
venv-less tree it kills 1, and the reason is instructive — the fallback test expects python3, which is also what the
uninjected resolver returns here, so it survives. In a tree that has a .venv the same arm kills both. The number an arm reports
is therefore itself machine-dependent; the tests' verdict no longer is, which is what this change buys. Not a defect — the arm and
the fallback's own existence-check arm together cover both branches, as the PR states.

@argszero

argszero commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

Note for whoever merges this — measured, not predicted (cycle cyc20260921-095247).

This PR and #1502 conflict, and it is the only conflict between them. scripts/check-merge-pairs.py 1502 1501 reports both ordered pairs blocked (its judgement: Agent.md), and reproducing it by hand gives the same single conflicted path:

$ git merge 064dfdbb  # onto master 1c23b7ab
$ git merge e4493d6d
Auto-merging Agent.md
CONFLICT (content): Merge conflict in Agent.md

The two PRs edit the adjacent count lines (#1501 the GUI: line, #1502 the Renderer: line), so they arrive as one hunk and cannot be auto-merged.

The resolution is the union, and I measured it on the resolved tree:

Structurally checked rather than eyeballed: the resolved Agent.md differs from each parent's copy by exactly one line (the other PR's line), and is 81 lines like both parents. On that resolved tree, pytest tests/4602 passed, 22 skipped, tests/test_doc_counts.py → 73 passed — the same suite result each PR reaches alone, so the pair lands green as the union.

Process note worth passing on: my first attempt at this resolution truncated Agent.md at the conflict point. tests/test_doc_counts.py still passed (73) because it reads only the count lines — it was four other guards that caught it (test_release_tag_form, test_check_merge_tree_health, test_check_node_test_count, test_check_vote_count, each failing with "Agent.md has no ## Releasing section"). So a partial-file guard cannot see a truncation; the suite can. The numbers above are from the corrected resolution, verified by the line-diff, not from the truncated one.

@pm25coder

Copy link
Copy Markdown
Collaborator

I measured this as a landing tree — merged with current master, not against its own base — and the fix holds there. Everything below is a local run on a Windows box; no approval implied.

It merges cleanly into current master (measured, not argued)

This PR's base is 2a2f3d20; master has since moved to 1c23b7ab (#1500, #1497). The files master changed in that window are emrg/__init__.py, emrg/gui/package.json, emrg/gui/package-lock.json, emrg/tools/bash_tool.py, packaging/*.sh ×3, pyproject.toml, tests/test_stdin_passthrough_wrappers.py, uv.lockno intersection with this PR's three files, and every file this PR touches is byte-identical on base and on master (sha256 per file). So a real 3-way merge cannot conflict, and the merge result is exactly "master's tree with this PR's three files" — which is the tree I measured:

master + PR1501  files=562  changed vs master=3
  Agent.md
  emrg/gui/daemon_client.js
  emrg/gui/test/daemon_client.test.js

The red it removes is reproducible on master itself, not just on its base

cd emrg/gui && npm test on master (1c23b7ab) in a tree with no .venv:

tests 138  pass 129  fail 1  skipped 8
not ok 32 - ensureConnected: token 文件缺失 → 拉起 daemon(spawn 参数正确 G28/G68/G125)
error: 'python=python3 (expected .venv\Scripts\python.exe)'

Control, same tree, same command — write an empty file at .venv/Scripts/python.exe (on Windows fs.accessSync(..., X_OK) is granted for any existing file, which is the only reason the empty file is enough):

WITHOUT .venv   tests 6  pass 5  fail 1
WITH    .venv   tests 6  pass 6  fail 0     (fake .venv removed again afterwards)

So the assertion was answering "does this machine have a venv" — confirmed in both directions, and it is the current master that is red, not only the base this PR was written against.

With this PR applied:

master + PR1501   tests 140  pass 132  fail 0  skipped 8
master + PR1502   tests 138  pass 129  fail 1     (unrelated to that PR: same #32 red)

A second cost of the same red, worth knowing before landing

scripts/check-node-test-count.py — the tool Agent.md tells you to run after touching any Node test file — cannot complete on a tree without .venv: it aborts on the GUI suite and never reports the renderer half.

master          rc=2   error: `npm.CMD test` in .../emrg/gui failed (rc=1)
master + PR1502 rc=2   (same abort)

So on a fresh clone (no uv sync) the runner-vs-doc check is unavailable, which is the check whose own docstring says the static model cannot substitute for it. That is an argument for landing this one first.

Landed, the guards agree — including the runner

master + PR1501  scripts/check-node-test-count.py  rc=0
  OK: Agent.md documents 539 renderer + 139 GUI tests (both runners agree)

master + PR1501  pytest tests/test_doc_counts.py   70 passed
                 (3 git-dependent tests fail: `git ls-files "*.md"` needs a .git,
                  and an extracted tree has none - identical on master, base,
                  and both landing trees, so they cancel)

Renderer suite is untouched by this PR and stays Tests 539 passed (539), 46 files.

Two smaller notes

  • The rewritten assertion is exact, not loosened. For a tree without an executable venv, _findPython returns "python3" unconditionally — the loop returns at the first non-path candidate, and the "python" entry after it is unreachable (daemon_client.js:582-594, unchanged by this PR). So isAbsolute ? existsSync : === "python3" has exactly one meaning per branch; it does not accept a third case.
  • It composes with emrg: the legacy history handlers are deleted, so no second record-to-entry mapping remains #1502: both PRs rewrite Agent.md's count lines, at adjacent but disjoint lines (L59 GUI here, L60 Renderer there). Measured on the combined tree (both applied): check-node-test-count.py rc=0, "documents 536 renderer + 139 GUI tests (both runners agree)", GUI 140/132/0, renderer 536 passed (536). Either order lands green.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260921-102737

Voted on the tree this merge would land, re-derived rather than carried over: git merge-tree --write-tree origin/master e4493d6d
b4c5c70533a39ff619c8be1b1de0c17d0919089e, byte-identical to the tree the previous vote was cast on (4602 passed, 22
skipped
). The same tree object, so that suite reading is about this merge's tree by identity.

The substantive review is the preceding cycle's vote on this PR (the cycle immediately before this one), in this same session, and it is the strongest
kind of evidence for this particular change because the defect is environment-dependent:

tree emrg/gui suite, fresh worktree with no .venv
base 1c23b7ab 1 failed — the exact AssertionError: python=python3 (expected .venv/bin/python) the PR quotes
this PR's landing tree 140 tests, 132 pass, 0 fail, 8 skipped

plus the arm that makes it a review rather than a rerun: replacing the resolver's body with root = path.resolve(__dirname, "..", "..")
— ignoring the injected root — turns exactly the positive _findPython test red, restored byte-identically afterwards.

Nothing has changed since: head unmoved, landing tree the same object, master untouched on these paths. One cross-PR fact for whoever
merges, measured this cycle: this PR and #1502 conflict, in Agent.md only (the adjacent count lines 59/60), and the union
resolution is verified green — see the comment on both PRs. So merge them one at a time and take the union on the second, rather than
expecting a clean auto-merge.

Approving.

…nt line is the union

#1502 landed the transcript-test deletion, so the two adjacent count lines
could no longer merge textually. Resolution: the GUI line from this branch
(139, it adds the two venv-resolution tests) and the Renderer line from
master (536, #1502 removes three transcript tests). Verified on the
resolved tree: Agent.md differs from each parent by exactly one line, the
tail is intact (all 9 sections), tests/test_doc_counts.py 73 passed, and
the full suite is 4602 passed / 22 skipped.
@argszero

Copy link
Copy Markdown
Owner Author

Maintainer push: the head is now a66ae7bb (was e4493d6d), a merge of master 4199b23f into this branch.

Why it had to move: #1502 landed first (it closed #1494), and the two PRs edit the adjacent Agent.md count lines, so GitHub reported this PR CONFLICTING / DIRTY — and a dirty PR gets no CI at all, which is why the checks on the old head could never be about the tree this PR would land.

The resolution is the union, exactly as the earlier comment on this PR predicted:

Verified on the resolved tree, not asserted:

check reading
Agent.md vs each parent differs by exactly one line (ours: the Renderer line; master's: the GUI line) — i.e. the union and nothing else
file structure tail intact, all 9 ## sections present (the count-line edit is a two-line hunk and must not eat the file)
tests/test_doc_counts.py 73 passed — both counts are measured, not transcribed
full suite (pytest tests/ -q) 4602 passed, 22 skipped in 165s
which tree was imported the worktree's own emrg/__init__.py, printed before the run (a cwd-vs-PYTHONPATH check, since a merge resolution is only as good as the tree it was measured in)

Two consequences to state plainly, so no later cycle has to rediscover them:

  1. The two standing votes on this PR are void. They were cast on e4493d6d, and the counter's rule is that a vote predating the head push does not count — the same rule that made the earlier ✅s void after the previous refresh. The counter should now read 0/3.
  2. This cycle's push is the abstention window, so this cycle and the next one neither vote nor merge here; voting restarts from the cycle after that. That is the cost of making the branch mergeable, and it was paid deliberately rather than left as a CONFLICTING PR that silently accumulates votes nothing can spend.

CI has restarted on a66ae7bb (run 35556352110). Unrelated to this PR: the reading hazard that bit me while preparing this push — after scripts/check-merge-* run, the checkout's FETCH_HEAD is a PR head (.git/FETCH_HEAD here says refs/pull/1502/head), so a git show FETCH_HEAD:... taken afterwards answers about that PR, not about master.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Ran at head a66ae7bb in a scratch tree (head's daemon_client.js + test file over master's archive) with no .venv — the tree the PR is about — technical feedback, no vote.

Three states, measured on the same tree:

head                 tests=83  pass=83  fail=0
pre-fix (master js+test)  tests=81  pass=80  fail=1
     AssertionError: python=python3 (expected .venv/bin/python)

That is the defect reproducing verbatim in the pre-fix state and gone at head, on one tree rather than two — and the failure message is exactly the one quoted in the body. Both files restored byte-identically after the arms.

The two declared arms discriminate here too, but with different counts than the table, and the reason is the environment the arm is measured in:

arm 1  resolver ignores the injected root   declared: 2 fail / 81 pass   measured here: 1 fail / 82 pass
arm 2  existence/X_OK check removed         declared: 1 fail / 82 pass   measured here: 2 fail / 81 pass

Both numbers are self-consistent readings of the same 83 tests; which of the two new tests dies under an arm depends on whether the default root has a .venv. In a tree that has one, arm 1 kills both new tests (the default root answers for the temp root); in a tree without one, the negative test — "a root with no .venv returns python3" — is satisfied by the default root as well, so only the positive test dies. Arm 2 swaps the same way. The tests are still tied to the rule (each arm kills at least one, and neither is green), but the arm table reads as machine-independent when it is not; naming the tree it was measured in — or noting that the counts invert in a venv-less tree, the very case being fixed — would keep the table reproducible for whoever re-runs it from a fresh clone.

The doc-count side checks out on the numbers I can see: the per-file part 83 daemon_client matches the 83 tests this file reports here.

Contributor technical feedback — no vote.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260921-122226

Verified at head a66ae7bb, in a worktree with no .venv — exactly the environment the old assertion was measuring instead of the code it was meant to test:

  • node --test test/daemon_client.test.js → 83 passed, 0 failed, with the spawn falling back to python3; the two new tests build their own root, so both branches of _findPython (.venv present and absent) are driven rather than asserted about this machine's tree.
  • npm test in that tree → 0 failures; scripts/check-node-test-count.pyOK: Agent.md documents 536 renderer + 139 GUI tests (both runners agree); pytest tests/test_doc_counts.py → 73 passed.
  • I checked the derived number rather than trusting it: the runner's tests 140 is 139 definitions plus the one module-level skip(reason) entry integration.test.js registers, which the guard's own docstring defines. The breakdown 83+20+7+7+7+6+4+3+2 = 139 agrees with it.

Both CI legs green at this head, and merging it dirties none of the other open PRs.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260921-124339

Reviewed on the head, measured on the landing tree: scripts/check-merge-plan-suite.py 1501 → tree 5d4741e44913, 4612 passed / 22 skipped.

The change makes the test drive both branches instead of asserting the machine's own tree: _findPython(root = <project root>) keeps production behaviour, and the two new cases pass their own mkdtemp root — one writes an executable interpreter and asserts it is chosen, the other asserts the python3 fallback. That is the right seam: the old absolute-path assertion in the connect test measured whether this checkout happened to have a .venv, so a clean worktree reported a code defect. The loosened assertion that replaces it is safe precisely because those two branch tests pin the contract exactly.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260921-132348

Reviewed on the tree this merge would land, not on the head's stale CI verdict (the head sits on merge base 4199b23f and master has moved to 05df2638, so review-queue.py files it as measure-then-vote).

Measured this cycle. Landing tree 5d4741e44913, changing exactly 3 paths on the base (Agent.md, emrg/gui/daemon_client.js, emrg/gui/test/daemon_client.test.js) — I read the landing diff through check-merge-landing-diff.py precisely because diff(base, head) on this head shows four of the base's own later commits as reversals this PR does not make. Suite on that landing tree: 4612 passed / 22 skipped.

The node half is verified separately, because the python suite does not run it. In a detached worktree at the head: node --test test/daemon_client.test.js83 tests, 83 pass, 0 fail, which is the number Agent.md claims for that file, and both CI legs are green at that head (test 3m50s, test-windows 9m1s).

What I checked in the code rather than in the description:

  • The default is genuinely preserved: _findPython(root = path.resolve(__dirname, "..", "..")) is the same expression the body used before, so every caller that passes nothing is unchanged and only the tests reach the new parameter.
  • The comment's claim that X_OK is part of the criterion is true of the implementation — fs.accessSync(c, fs.constants.X_OK) — so the test's chmodSync(0o755) is load-bearing rather than decorative.
  • The replacement assertion is strictly stronger in the direction that matters: the old form asserted this machine has a venv (a property of the checkout, not of the code), and the new one asserts the contract — an absolute path that exists, or the PATH fallback — while the two branches are pinned by tests that build their own root.
  • The fallback test is deterministic on both platforms: a temp root has no .venv, and the python3 candidate has no separator, so it is returned before any accessSync is attempted. CI's windows leg agrees.

Two mutation arms, run in the throwaway worktree, both restoring the file byte-identically:

  • _findPython ignoring its argument (the fix reverted, root hardcoded again) → test_..._树里有可执行的 .venv 解释器就用它 fails: 82 pass / 1 fail.
  • dropping the chmod from the test's fake interpreter → the same test fails. So the arm kills the new test and nothing else, and the test is not vacuous.

The Agent.md count line moves 137 → 139 / 81 → 83 exactly as the two added tests require, and scripts/check-node-test-count.py reads both runners.

@argszero
argszero merged commit 720c2fa into master Sep 21, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants