Skip to content

Commit dd584fa

Browse files
authored
fix(runtime): discover Python interpreters without fixed minor lists (#4482)
* docs(development): finish version-neutral Python entrypoints Signed-off-by: song <liusongstep@gmail.com> * fix(runtime): discover installed Python minor versions Signed-off-by: song <liusongstep@gmail.com> * docs(runtime): explain Python discovery precedence Signed-off-by: song <liusongstep@gmail.com> --------- Signed-off-by: song <liusongstep@gmail.com>
1 parent dd9a95c commit dd584fa

12 files changed

Lines changed: 258 additions & 55 deletions

File tree

‎AGENTS.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,22 @@ broader actor lifecycle or authority model than the implementation provides.
222222

223223
## Engineering Quality And Right-Sized Scope
224224

225+
### Source-Checkout Python Entry Points
226+
227+
Run source development and validation from the intended worktree root with
228+
`uv run --extra test python ...` or `uv run --extra test loopx ...`. Use
229+
`uv sync --extra test` to prepare the project environment. An explicitly
230+
activated compatible environment with the checkout installed remains valid.
231+
Check `sys.executable` and `loopx.__file__` when interpreter or source provenance
232+
is uncertain; a global `loopx` may point to another release snapshot.
233+
234+
Keep Python subprocesses on the selected interpreter (`sys.executable`), and
235+
keep bootstrap interpreter discovery, supported-version declarations, CI
236+
version coverage, and version-specific fixtures intact. Do not replace those
237+
with a nested `uv run`, rewrite historical execution receipts, or commit a
238+
generated `uv.lock` as part of an unrelated change. See the testing and quality
239+
guide for the validation layers and the source-checkout environment boundary.
240+
225241
### Refactor Real-Path Validation
226242

227243
Before delivering a refactor, validate the affected production entrypoint and

‎apps/presentation/dashboard/README.md‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,13 +147,21 @@ LoopX services are already running separately. Vite proxies the default
147147
The full-stack launcher needs a Python 3.11+ interpreter for the status and
148148
Chat services. It honors `LOOPX_PYTHON` first, then the Python recorded by the
149149
LoopX installer in `.loopx-python`, then the repository `.venv`,
150-
`python3.13`/`python3.12`/`python3.11` on `PATH`, and common Homebrew locations.
151-
If your default `python3` is older, point it at an existing interpreter:
150+
versioned interpreters discovered on `PATH` in descending numeric order, the
151+
unversioned `python3`, and common Homebrew locations. Every discovered executable
152+
must pass the Python compatibility probe; there is no fixed minor-version list.
153+
Prepare the project environment and launch from the repository root:
152154

153155
```bash
154-
LOOPX_PYTHON=/path/to/python3.12 npm run dev
156+
uv sync --extra test
157+
uv run --extra test bash scripts/dashboard-dev.sh
155158
```
156159

160+
An explicit `LOOPX_PYTHON` or a valid installer-recorded interpreter still takes
161+
precedence. To select the project environment explicitly after `uv sync`, set
162+
`LOOPX_PYTHON` to the absolute path of `.venv/bin/python`. The launcher continues
163+
to support existing compatible Python installations without requiring uv.
164+
157165
Both the root dashboard and the packaged `/chat/` route expose the same
158166
installable PWA manifest and icons. The default `loopx dashboard` command opens
159167
`/chat/`; its manifest therefore scopes the installed app to `/chat/`. This is

‎docs/guides/personal-workspace-trial-guide.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,10 @@ bash scripts/dashboard-dev.sh
6060
```
6161

6262
```bash
63-
python3.13 -m loopx.cli chat-endpoint add --config kimi.json # 添加
64-
python3.13 -m loopx.cli chat-endpoint list # 查看
65-
python3.13 -m loopx.cli chat-endpoint remove --agent-id kimi # 移除
63+
# 在仓库根目录运行,使用当前源码的项目环境
64+
uv run loopx chat-endpoint add --config kimi.json # 添加
65+
uv run loopx chat-endpoint list # 查看
66+
uv run loopx chat-endpoint remove --agent-id kimi # 移除
6667
```
6768

6869
自定义 Agent 信任范围是 read_only,写入同样走预览确认。

‎packages/dsh-loopx-plugin/README.md‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,14 @@ deferred atomicity limit are specified in the versioned
3939
Requirements are Node.js 22.19+, `pnpm`, Python 3.11+ with `pip`, and network
4040
access for the first DSH start when no compatible LoopX CLI is already
4141
installed. LoopX itself is deliberately not a prerequisite. The initializer
42-
honors an explicit `PYTHON_BIN`, otherwise it checks `python3`, `python3.14`, `python3.13`,
43-
`python3.12`, and `python3.11` and keeps the first interpreter that satisfies
44-
the requirement. If it must install or upgrade LoopX, it writes an isolated
42+
honors an explicit `PYTHON_BIN`; otherwise it tries `python3`, then discovers
43+
`python3.<minor>` executables on the supplied `PATH` in descending numeric order.
44+
Installation still checks the Python version and pip; reopening the managed
45+
runtime uses the same discovery and validates the LoopX CLI. No hard-coded
46+
minor-version list is maintained. When selecting Python for installation, an
47+
invalid explicit interpreter fails instead of falling back. Managed-runtime
48+
readback keeps the existing CLI fallback behavior. If the plugin must install
49+
or upgrade LoopX, it writes an isolated
4550
copy under `$DSH_AGENTS_HOME/runtime/dsh-loopx-plugin` (default
4651
`~/.agents/runtime/dsh-loopx-plugin`) and never mutates the system Python
4752
environment. This works with externally managed Python distributions that

‎packages/dsh-loopx-plugin/src/managed-runtime.ts‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,13 @@
11
import { stat } from 'node:fs/promises'
2+
import { readdirSync } from 'node:fs'
23
import { homedir } from 'node:os'
3-
import { join, resolve } from 'node:path'
4+
import { delimiter, join, resolve } from 'node:path'
45
import { resolveLoopXCommand, runFile } from './cli.ts'
56
import type { FileRunner, LoopXCommand } from './cli.ts'
67

78
export const MANAGED_LAUNCHER_NAME = 'loopx_cli.py'
89
export const MANAGED_SITE_PACKAGES_NAME = 'site-packages'
910

10-
const PYTHON_CANDIDATES = Object.freeze([
11-
'python3',
12-
'python3.14',
13-
'python3.13',
14-
'python3.12',
15-
'python3.11',
16-
])
17-
1811
export interface LoopXRuntimeOptions {
1912
readonly runner?: FileRunner | undefined
2013
readonly signal?: AbortSignal | undefined
@@ -35,7 +28,26 @@ export function pluginPythonCandidates(
3528
options: LoopXRuntimeOptions,
3629
): readonly string[] {
3730
const explicit = configuredPluginPython(options)
38-
return explicit === undefined ? PYTHON_CANDIDATES : [explicit]
31+
if (explicit !== undefined) return [explicit]
32+
const env = options.env ?? process.env
33+
const searchPath = env.PATH ?? env.Path
34+
const discovered = new Map<string, number>()
35+
for (const directory of searchPath?.split(delimiter) ?? []) {
36+
try {
37+
for (const entry of readdirSync(directory || '.', { withFileTypes: true })) {
38+
const match = /^python3\.(\d+)(?:\.exe)?$/i.exec(entry.name)
39+
if (match?.[1] !== undefined && !entry.isDirectory()) {
40+
discovered.set(entry.name, Number(match[1]))
41+
}
42+
}
43+
} catch {
44+
// Missing or unreadable PATH entries are not interpreter candidates.
45+
}
46+
}
47+
// Preserve python3-first behavior; callers still probe the actual version/pip.
48+
return ['python3', ...[...discovered]
49+
.sort(([nameA, minorA], [nameB, minorB]) => minorB - minorA || nameA.localeCompare(nameB))
50+
.map(([name]) => name)]
3951
}
4052

4153
export function pluginAgentsHome(options: LoopXRuntimeOptions): string {

‎packages/dsh-loopx-plugin/tests/admission-closeout.integration.spec.ts‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { describe, expect, it } from 'vitest'
1111
import { runFile } from '../src/cli.ts'
1212
import type { FileRunner } from '../src/cli.ts'
1313
import { LoopXContinuationDriver } from '../src/driver.ts'
14+
import { pluginPythonCandidates } from '../src/managed-runtime.ts'
1415

1516
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
1617
const sessionId = 'dsh-closeout-session'
@@ -19,14 +20,7 @@ const agentId = 'dsh-closeout-agent'
1920
const todoId = 'todo_dsh_closeout'
2021
const turnInstanceId = 'dsh-closeout-turn-1'
2122
const pythonVersionProbe = 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)'
22-
const pythonCandidates = [...new Set([
23-
process.env.PYTHON_BIN,
24-
'python3',
25-
'python3.14',
26-
'python3.13',
27-
'python3.12',
28-
'python3.11',
29-
].filter((value): value is string => value !== undefined && value.length > 0))]
23+
const pythonCandidates = pluginPythonCandidates({ env: process.env })
3024

3125
interface Fixture {
3226
readonly project: string

‎packages/dsh-loopx-plugin/tests/init-command.spec.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
22
import { tmpdir } from 'node:os'
33
import { join } from 'node:path'
4-
import { describe, expect, it } from 'vitest'
4+
import { describe, expect, it, onTestFinished } from 'vitest'
55
import type { Context } from '@deepseek-ai/cordis'
66
import type { Agent } from '@deepseek-ai/dsh-agent'
77
import type {
@@ -337,6 +337,11 @@ describe('/loopx-init implementation', () => {
337337
})
338338

339339
it('selects an available Python 3.11+ interpreter for install and readback', async () => {
340+
const pythonPath = await mkdtemp(join(tmpdir(), 'loopx-installed-pythons-'))
341+
onTestFinished(() => rm(pythonPath, { recursive: true, force: true }))
342+
for (const name of ['python3.14', 'python3.13']) {
343+
await writeFile(join(pythonPath, name), '# fixture executable\n')
344+
}
340345
const calls: Array<{ readonly file: string; readonly args: readonly string[] }> = []
341346
let installed = false
342347
const runner: FileRunner = async (file, args) => {
@@ -374,6 +379,7 @@ describe('/loopx-init implementation', () => {
374379

375380
const result = await initializeLoopX({
376381
runner,
382+
env: { PATH: pythonPath },
377383
skillsDir: '/fixture/skills',
378384
runtimeDir: '/fixture/runtime',
379385
})
@@ -406,6 +412,11 @@ describe('/loopx-init implementation', () => {
406412
})
407413

408414
it('skips an implicit version-compatible Python that cannot run pip', async () => {
415+
const pythonPath = await mkdtemp(join(tmpdir(), 'loopx-installed-pythons-'))
416+
onTestFinished(() => rm(pythonPath, { recursive: true, force: true }))
417+
for (const name of ['python3.14']) {
418+
await writeFile(join(pythonPath, name), '# fixture executable\n')
419+
}
409420
const calls: Array<{ readonly file: string; readonly args: readonly string[] }> = []
410421
let installed = false
411422
const runner: FileRunner = async (file, args) => {
@@ -450,6 +461,7 @@ describe('/loopx-init implementation', () => {
450461

451462
const result = await initializeLoopX({
452463
runner,
464+
env: { PATH: pythonPath },
453465
skillsDir: '/fixture/skills',
454466
runtimeDir: '/fixture/runtime',
455467
})
@@ -493,7 +505,8 @@ describe('/loopx-init implementation', () => {
493505
stage: 'install_cli',
494506
causeKind: 'missing',
495507
})
496-
expect(calls.some(call => call.file === 'python3.14')).toBe(false)
508+
expect(calls.filter(call => call.args[0] === '-c')
509+
.every(call => call.file === '/configured/python')).toBe(true)
497510
expect(calls.some(call => (
498511
call.file === '/configured/python'
499512
&& call.args.join(' ') === '-m pip --version'
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
2+
import { tmpdir } from 'node:os'
3+
import { delimiter, join } from 'node:path'
4+
import { describe, expect, it } from 'vitest'
5+
import { pluginPythonCandidates } from '../src/managed-runtime.ts'
6+
7+
describe('installed Python discovery', () => {
8+
it('discovers unlisted minors in numeric order without executable suffix lookalikes', async () => {
9+
const directory = await mkdtemp(join(tmpdir(), 'loopx-python-discovery-'))
10+
try {
11+
for (const name of ['python3.9', 'python3.97', 'python3.12', 'python3.999-config']) {
12+
await writeFile(join(directory, name), '# fixture executable\n')
13+
}
14+
await mkdir(join(directory, 'python3.998'))
15+
expect(pluginPythonCandidates({ env: { PATH: directory } })).toEqual([
16+
'python3', 'python3.97', 'python3.12', 'python3.9',
17+
])
18+
expect(pluginPythonCandidates({ env: { PATH: `${directory}${delimiter}${directory}` } }))
19+
.toEqual(['python3', 'python3.97', 'python3.12', 'python3.9'])
20+
} finally {
21+
await rm(directory, { recursive: true, force: true })
22+
}
23+
})
24+
25+
it('keeps an explicit interpreter authoritative and tolerates missing PATH directories', () => {
26+
expect(pluginPythonCandidates({ pythonBin: '/configured/python', env: { PATH: '/missing' } }))
27+
.toEqual(['/configured/python'])
28+
expect(pluginPythonCandidates({ env: { PATH: '/missing', PYTHON_BIN: '/custom/python' } }))
29+
.toEqual(['/custom/python'])
30+
expect(pluginPythonCandidates({ env: { PATH: '/missing' } })).toEqual(['python3'])
31+
})
32+
})

‎scripts/dashboard-dev.sh‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,10 @@ trap cleanup EXIT INT TERM
120120

121121
if ! PYTHON_BIN="$(bash "${SCRIPT_DIR}/loopx-python.sh")"; then
122122
echo "LoopX requires Python 3.11 or newer to start status and Chat services." >&2
123-
echo "Install Python 3.11+ (for example: brew install python@3.12), or set" >&2
124-
echo "LOOPX_PYTHON to an existing Python 3.11+ executable and retry, e.g.:" >&2
125-
echo " LOOPX_PYTHON=/path/to/python3.12 npm run dev" >&2
123+
echo "From the repository root, prepare a project environment and retry:" >&2
124+
echo " uv sync --extra test" >&2
125+
echo " uv run --extra test bash scripts/dashboard-dev.sh" >&2
126+
echo "Or set LOOPX_PYTHON to an existing Python 3.11+ executable." >&2
126127
echo "Starting the Vite UI only; use 'npm run dev:web' for the same UI-only preview." >&2
127128
cd "${DASHBOARD_DIR}"
128129
exec npm run dev:web

‎scripts/install-local.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Options:
1212
-h, --help Show this help and exit.
1313
1414
Common environment variables:
15-
LOOPX_PYTHON=/path/to/python3.11 Use this supported Python for the release.
15+
LOOPX_PYTHON=/path/to/python Use a Python 3.11+ executable for the release.
1616
LOOPX_PROMOTE_DEFAULT=1 Promote this checkout as the default loopx.
1717
LOOPX_INSTALL_CANARY=0 Skip the loopx-canary executable.
1818
LOOPX_INSTALL_SKILL=0 Skip packaged workflow skills.

0 commit comments

Comments
 (0)