Browse Source

fix(subprocess): support packaged native runner

pku-xht 1 month ago
parent
commit
273c2847e5

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
-2026-07-10-single-file-executable-sdk-runtime-distribution.md: 40433d99e5d1aa569c3fdf094a280d3de62ad588
-2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: cff72ae10eb82c65c499123cc559cc6ad7e440ab
+2026-07-10-single-file-executable-sdk-runtime-distribution.md: 9b2707b3298c0649b7b2aed94980c0b7acee5c5c
+2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: f21e22cfe89106f24beedf22ff1904b5160201e8

File diff suppressed because it is too large
+ 1 - 1
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md


File diff suppressed because it is too large
+ 1 - 1
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md


+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md
-2026-08-20-subprocess-native-containment.md: d51b675556715aa7fe213d72a5f8fd62536fdd69
-2026-08-20-subprocess-native-containment.zh.md: d556efa217e622f2b7123e07497f966dbef21ece
+2026-08-20-subprocess-native-containment.md: 45183c2c381da24433bfc20052a603beefd0afc5
+2026-08-20-subprocess-native-containment.zh.md: 4ead6b59502306117203b834b9151fc0fe2018b0

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md

@@ -14,13 +14,13 @@ The local subprocess provider treated a POSIX process group or a Windows direct-
 
 The common spawn lifecycle still owns stdio dispositions, bounded collection, direct outcome, abort handling, TERM-to-KILL escalation, and host-exit registration. `.done` comes from the target process. A private `0600` single-spawn request/event transport lets the Linux or Windows runner report Node-shaped target spawn failures and the target exit independently of the scope or Job lifetime. `waitForExit()` succeeds only after the same owner used by `terminate()` confirms that the OS range is empty; once confirmed, the owner permanently ignores later signals.
 
-Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. Scope TERM leaves the runner alive long enough to report a TERM-trapping target; scope KILL is itself authoritative when no runner result can survive. Windows target descendants inherit the Job by default, while the runner remains until both the direct result is reported and `QueryInformationJobObject` reports zero active Job members. Parent IPC disconnect terminates the Job during JavaScript-observable host exit.
+Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target; scope KILL is itself authoritative when no runner result can survive. Windows target descendants inherit the Job by default, while the runner remains until both the direct result is reported and `QueryInformationJobObject` reports zero active Job members. Parent IPC disconnect terminates the Job during JavaScript-observable host exit.
 
 When native capability is unavailable before target execution, the provider warns once and uses the existing PGID or `taskkill /T` fallback. macOS always takes that path because it has no supported public persistent process owner. After native launch is selected, any runner, manager, or result-transport failure is reported; the user command is never replayed through fallback.
 
 ## Verification
 
-Linux native evidence ran against an Ubuntu 24.04 x86_64 user manager with systemd 255.4 and covers a real `setsid` descendant, a double-fork daemon whose direct parent exits first, and Node-shaped spawn failures without replay. Windows native evidence covers a default-inheritance descendant and a descendant that remains after the direct target exits. Shared tests pin direct exit versus range quiescence, literal argv, one-time fallback warnings, no post-stop signals, abort and host-exit routing, and both source and built runner entries.
+Linux native evidence ran against an Ubuntu 24.04 x86_64 user manager with systemd 255.4 and covers a real `setsid` descendant, a double-fork daemon whose direct parent exits first, and Node-shaped spawn failures without replay. Windows native evidence covers a default-inheritance descendant and a descendant that remains after the direct target exits. Shared tests pin direct exit versus range quiescence, literal argv, one-time fallback warnings, no post-stop signals, abort and host-exit routing, and source, built, and packaged-executable runner entries.
 
 ## Alternatives considered
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md

@@ -14,13 +14,13 @@ Status: implemented
 
 common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、TERM-to-KILL 升级与 host-exit 注册。`.done` 来自 target process。private `0600` single-spawn request/event transport 让 Linux 或 Windows runner 分别报告 Node-shaped target spawn failure 与 target exit,不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。
 
-Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;scope KILL 无法保留 runner result 时,该 KILL 事实本身就是权威结果。Windows target descendant 默认继承 Job;runner 会一直存活到 direct result 已报告且 `QueryInformationJobObject` 报告 Job active member 归零。parent IPC 断开会在 JavaScript-observable host exit 期间终止 Job。
+Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;scope KILL 无法保留 runner result 时,该 KILL 事实本身就是权威结果。Windows target descendant 默认继承 Job;runner 会一直存活到 direct result 已报告且 `QueryInformationJobObject` 报告 Job active member 归零。parent IPC 断开会在 JavaScript-observable host exit 期间终止 Job。
 
 native capability 在目标执行前不可用时,provider 只告警一次并使用既有 PGID 或 `taskkill /T` fallback。macOS 因没有受支持的公开 persistent process owner,始终进入该路径。native launch 一旦被选择,runner、manager 或 result transport 的任何失败都会直接报告;用户命令绝不会经 fallback 重放。
 
 ## Verification
 
-Linux native 证据已在 Ubuntu 24.04 x86_64、systemd 255.4 的 user manager 上运行,覆盖真实 `setsid` descendant、direct parent 先退出的 double-fork daemon,以及不重放的 Node-shaped spawn failure。Windows native 证据覆盖默认继承 descendant,以及 direct target 退出后仍存活的 descendant。shared tests 固定 direct exit 与 range quiescence 的区别、literal argv、一次性 fallback warning、停稳后不再发 signal、abort 与 host-exit 路由,以及 source 和 built runner entry。
+Linux native 证据已在 Ubuntu 24.04 x86_64、systemd 255.4 的 user manager 上运行,覆盖真实 `setsid` descendant、direct parent 先退出的 double-fork daemon,以及不重放的 Node-shaped spawn failure。Windows native 证据覆盖默认继承 descendant,以及 direct target 退出后仍存活的 descendant。shared tests 固定 direct exit 与 range quiescence 的区别、literal argv、一次性 fallback warning、停稳后不再发 signal、abort 与 host-exit 路由,以及 source、built 和 packaged-executable runner entry。
 
 ## Alternatives considered
 

+ 2 - 2
docs/subsystems/subprocess.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/subprocess.md
-subprocess.md: a2b0a5575b28d3a0ba859fc56d0a2ae7be008175
-subprocess.zh.md: b923cad98bb3dd999c6f4ca8fa5c7e160cebe774
+subprocess.md: bb73f045cda88d3dd46443e9376e4a1991a08a42
+subprocess.zh.md: 063c125eb3270c5a493a4894919ea58c1a3de024

+ 1 - 1
docs/subsystems/subprocess.md

@@ -282,7 +282,7 @@ Abstract subprocess service. Subclass, implement spawn, and load the subclass as
 Implementations must honor these semantics:
 
 - Executable paths belong to one execution world shared with the mounted filesystem provider.
-- spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level failures.
+- spawn returns a live handle synchronously after provider-specific setup needed to publish its target pid. `done` resolves with direct-process exit facts and may reject for spawn or selected provider-runner failures.
 - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.
 - SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence.
 - Disposal of the service terminates all still-running managed processes and awaits their exit.

+ 1 - 1
docs/subsystems/subprocess.zh.md

@@ -282,7 +282,7 @@ Abstract subprocess service. Subclass, implement spawn, and load the subclass as
 Implementations must honor these semantics:
 
 - Executable paths belong to one execution world shared with the mounted filesystem provider.
-- spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level failures.
+- spawn returns a live handle synchronously after provider-specific setup needed to publish its target pid. `done` resolves with direct-process exit facts and may reject for spawn or selected provider-runner failures.
 - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.
 - SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence.
 - Disposal of the service terminates all still-running managed processes and awaits their exit.

+ 2 - 1
packages/examples/jsonrpc-demo/package.json

@@ -45,7 +45,8 @@
   ],
   "license": "MIT",
   "dependencies": {
-    "@deepseek-ai/dsh-app-boot": "workspace:^"
+    "@deepseek-ai/dsh-app-boot": "workspace:^",
+    "@deepseek-ai/dsh-subprocess-local": "workspace:^"
   },
   "peerDependencies": {
     "@deepseek-ai/dsh-invariants": "workspace:^",

+ 10 - 2
packages/examples/jsonrpc-demo/src/packaged-bin.ts

@@ -8,5 +8,13 @@
 
 import { runJsonrpcAgent } from './runner.ts'
 
-/* v8 ignore next -- exercised through the built Python runtime carriers */
-await runJsonrpcAgent(import.meta.url)
+/* v8 ignore start -- exercised through the built Python runtime carriers */
+const PACKAGED_RUNNER_ARG = '--dsh-internal-subprocess-runner'
+
+if (process.argv[2] === PACKAGED_RUNNER_ARG) {
+  process.argv.splice(2, 1)
+  await import('@deepseek-ai/dsh-subprocess-local/spawn-runner')
+} else {
+  await runJsonrpcAgent(import.meta.url)
+}
+/* v8 ignore stop */

+ 3 - 0
packages/examples/jsonrpc-demo/tsconfig.json

@@ -17,6 +17,9 @@
     {
       "path": "../../boot/app-boot"
     },
+    {
+      "path": "../../subprocess/subprocess-local"
+    },
     {
       "path": "../../runtime-diagnostics/invariants"
     }

+ 1 - 1
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -1866,7 +1866,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
   {
     key: 'subprocess',
     summary: 'Abstract subprocess service.',
-    description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.',
+    description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously after provider-specific setup needed to publish its target pid. `done` resolves with direct-process exit facts and may reject for spawn or selected provider-runner failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.',
     methods: [
       {
         signature: 'abstract resolveExecutable( command: string, env?: Readonly<Record<string, string>>, signal?: AbortSignal, ): Promise<string>',

+ 20 - 6
packages/subprocess/subprocess-local/src/linux-scope.ts

@@ -35,10 +35,19 @@ interface SystemctlResult {
 
 const SYSTEMCTL_TIMEOUT_MS = 5_000
 const SCOPE_POLL_INTERVAL_MS = 200
+const MISSING_UNIT = /\bunit\b[^\r\n]*(?:could not be found|not found|not loaded)/iu
+
+function systemctlEnv(): NodeJS.ProcessEnv {
+  return { ...process.env, LC_ALL: 'C' }
+}
 
 function querySystemctl(command: string, args: readonly string[]): Promise<SystemctlResult> {
   return new Promise((resolve) => {
-    execFile(command, [...args], { encoding: 'utf8', timeout: SYSTEMCTL_TIMEOUT_MS }, (error, stdout, stderr) => {
+    execFile(command, [...args], {
+      encoding: 'utf8',
+      env: systemctlEnv(),
+      timeout: SYSTEMCTL_TIMEOUT_MS,
+    }, (error, stdout, stderr) => {
       const code = error === null ? 0 : (error as Error & { code?: string | number }).code
       resolve({
         status: typeof code === 'number' ? code : null,
@@ -61,11 +70,15 @@ function unitStem(prefix: string): string {
  */
 export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean {
   const runSync = internals.spawnSync ?? spawnSync
+  const invocation = internals.runnerInvocation ?? spawnRunnerInvocation()
+  const [runnerCommand, ...runnerPrefix] = invocation
+  if (runnerCommand === undefined) return false
   const systemdRun = internals.systemdRun ?? 'systemd-run'
   const systemctl = internals.systemctl ?? 'systemctl'
   const timeout = 5_000
   const manager = runSync(systemctl, ['--user', 'show-environment'], {
     encoding: 'utf8',
+    env: systemctlEnv(),
     stdio: 'ignore',
     timeout,
   })
@@ -78,9 +91,10 @@ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean {
     '--expand-environment=no',
     `--unit=${unitStem('dsh-subprocess-probe')}`,
     '--',
-    process.execPath,
-    '-e',
-    '',
+    runnerCommand,
+    ...runnerPrefix,
+    '--mode',
+    'probe-node',
   ], {
     env: childEnv(),
     stdio: 'ignore',
@@ -111,7 +125,7 @@ class SystemdScopeOwner implements BoundProcessOwner {
       '--kill-whom=all',
       `--signal=${signal}`,
       this.unit,
-    ], { encoding: 'utf8', timeout: SYSTEMCTL_TIMEOUT_MS })
+    ], { encoding: 'utf8', env: systemctlEnv(), timeout: SYSTEMCTL_TIMEOUT_MS })
     if (result.error === undefined && result.status === 0) {
       if (signal === 'SIGKILL') this.killConfirmed = true
       return
@@ -134,7 +148,7 @@ class SystemdScopeOwner implements BoundProcessOwner {
     ])
     const output = `${result.stdout}\n${result.stderr}`
     if (result.status !== 0) {
-      if (/not found|could not be found|no such/iu.test(output)) {
+      if (MISSING_UNIT.test(output)) {
         if (this.runner.exitCode !== null || this.runner.signalCode !== null) return false
       } else {
         if (result.error !== undefined) throw result.error

+ 2 - 0
packages/subprocess/subprocess-local/src/runner-launch.ts

@@ -18,12 +18,14 @@ import { childEnv } from './spawn.ts'
 const handshakeWait = new Int32Array(new SharedArrayBuffer(4))
 const RUNNER_HANDSHAKE_TIMEOUT_MS = 10_000
 const RUNNER_EVENT_POLL_MS = 100
+const PACKAGED_RUNNER_ARG = '--dsh-internal-subprocess-runner'
 
 /**
  * Resolve the built runner in production or its source entry in repository execution.
  * @returns Node executable and runner argv prefix.
  */
 export function spawnRunnerInvocation(): string[] {
+  if ('pkg' in process) return [process.execPath, PACKAGED_RUNNER_ARG]
   const builtEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/spawn-runner'))
   if (existsSync(builtEntry)) return [process.execPath, builtEntry]
   const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/spawn-runner.ts'))

+ 4 - 2
packages/subprocess/subprocess-local/src/spawn-runner.ts

@@ -19,6 +19,7 @@ import {
 import type { RunnerRequest, SerializedSpawnError } from './runner-protocol.ts'
 
 type RunnerArgs =
+  | { mode: 'probe-node' }
   | { mode: 'probe-win32' }
   | { mode: 'node' | 'win32'; requestPath: string; eventsPath: string }
 
@@ -35,7 +36,7 @@ function parseArgs(argv: string[]): RunnerArgs {
     else if (key === '--events') eventsPath = value
     else throw new Error(`subprocess runner unknown argument: ${String(key)}`)
   }
-  if (mode === 'probe-win32') return { mode }
+  if (mode === 'probe-node' || mode === 'probe-win32') return { mode }
   if (mode !== 'node' && mode !== 'win32') throw new Error(`subprocess runner unknown mode: ${String(mode)}`)
   if (requestPath === undefined || eventsPath === undefined) throw new Error('subprocess runner requires request and event paths')
   return { mode, requestPath, eventsPath }
@@ -167,6 +168,7 @@ async function runWin32(request: RunnerRequest, eventsPath: string): Promise<voi
 
 async function main(): Promise<void> {
   const args = parseArgs(process.argv.slice(2))
+  if (args.mode === 'probe-node') return
   if (args.mode === 'probe-win32') {
     loadWin32ProcessBindings()
     return
@@ -185,7 +187,7 @@ async function main(): Promise<void> {
 main().catch((error: unknown) => {
   try {
     const args = parseArgs(process.argv.slice(2))
-    if (args.mode !== 'probe-win32') {
+    if (args.mode !== 'probe-node' && args.mode !== 'probe-win32') {
       appendRunnerEvent(args.eventsPath, { type: 'runner-error', error: serializeSpawnError(error) })
     }
   } catch {

+ 19 - 5
packages/subprocess/subprocess-local/tests/linux-scope.spec.ts

@@ -29,14 +29,25 @@ function asyncQuery(runSync: typeof spawnSync) {
 describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () => {
   it('requires a readable user manager and literal-argument systemd support', () => {
     const calls: string[][] = []
-    const runSync = vi.fn((command: string, args: readonly string[]) => {
+    const environments: Array<NodeJS.ProcessEnv | undefined> = []
+    const runSync = vi.fn((command: string, args: readonly string[], options?: { env?: NodeJS.ProcessEnv }) => {
       calls.push([command, ...args])
+      environments.push(options?.env)
       return { status: 0, error: undefined }
     }) as unknown as typeof spawnSync
-    expect(probeLinuxScope({ spawnSync: runSync, systemdRun: 'systemd-run', systemctl: 'systemctl' })).toBe(true)
+    const runnerInvocation = ['node-runtime', 'runner-entry.js']
+    expect(probeLinuxScope({
+      spawnSync: runSync,
+      systemdRun: 'systemd-run',
+      systemctl: 'systemctl',
+      runnerInvocation,
+    })).toBe(true)
     expect(calls[1]).toContain('--expand-environment=no')
     expect(calls[1]).not.toContain('--pipe')
     expect(calls[1]).not.toContain('--wait')
+    const separator = calls[1]?.indexOf('--') ?? -1
+    expect(calls[1]?.slice(separator + 1)).toEqual([...runnerInvocation, '--mode', 'probe-node'])
+    expect(environments[0]?.LC_ALL).toBe('C')
 
     const oldSystemd = vi.fn((command: string) => ({
       status: command === 'systemctl' ? 0 : 1,
@@ -130,7 +141,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
     const runSync = vi.fn(() => ({
       status: 1,
       stdout: '',
-      stderr: 'Failed to connect to bus',
+      stderr: 'Failed to connect to bus: No such file or directory',
       error: undefined,
     })) as unknown as typeof spawnSync
     const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), {
@@ -300,7 +311,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
       expect(runSync).toHaveBeenCalledWith(
         'systemctl',
         expect.arrayContaining(['kill', '--kill-whom=all', '--signal=SIGKILL']),
-        expect.any(Object),
+        expect.objectContaining({ env: expect.objectContaining({ LC_ALL: 'C' }) }),
       )
     } finally {
       wrapper?.kill('SIGKILL')
@@ -313,6 +324,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
     const run = vi.fn()
     const runSync = vi.fn()
     const runAsync = vi.fn()
+    const queryEnvironments: Array<NodeJS.ProcessEnv | undefined> = []
     vi.resetModules()
     vi.doMock('node:child_process', async (importOriginal) => {
       const actual = await importOriginal<typeof import('node:child_process')>()
@@ -327,9 +339,10 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
       runAsync.mockImplementation((
         _command: string,
         args: readonly string[],
-        _options: unknown,
+        options: { env?: NodeJS.ProcessEnv },
         callback: (error: Error | null, stdout: string, stderr: string) => void,
       ) => {
+        queryEnvironments.push(options.env)
         if (queryFailure !== undefined) {
           callback(queryFailure, '', '')
           return
@@ -348,6 +361,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
       expect(run).toHaveBeenCalledWith('systemd-run', expect.any(Array), expect.any(Object))
       expect(runSync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object))
       expect(runAsync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object), expect.any(Function))
+      expect(queryEnvironments[0]?.LC_ALL).toBe('C')
 
       queryFailure = Object.assign(new Error('numeric systemctl failure'), { code: 17 })
       const numericFailure = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']))

+ 23 - 0
packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts

@@ -10,6 +10,7 @@ import {
   runnerDirectResult,
   runnerFiles,
   runnerStdio,
+  spawnRunnerInvocation,
 } from '../src/runner-launch.ts'
 import {
   appendRunnerEvent,
@@ -85,6 +86,28 @@ describe('spawn runner transport', () => {
     }
   })
 
+  it('re-enters a packaged executable through its private runner dispatch', () => {
+    const packagedProcess = process as NodeJS.Process & { pkg?: unknown }
+    const original = Object.getOwnPropertyDescriptor(packagedProcess, 'pkg')
+    Object.defineProperty(packagedProcess, 'pkg', { configurable: true, value: {} })
+    try {
+      expect(spawnRunnerInvocation()).toEqual([process.execPath, '--dsh-internal-subprocess-runner'])
+    } finally {
+      if (original === undefined) Reflect.deleteProperty(packagedProcess, 'pkg')
+      else Object.defineProperty(packagedProcess, 'pkg', original)
+    }
+  })
+
+  it('supports the node runner capability probe', () => {
+    const result = spawnSync(sourceInvocation[0] as string, [
+      ...sourceInvocation.slice(1),
+      '--mode',
+      'probe-node',
+    ], { encoding: 'utf8', timeout: 10_000 })
+    expect(result.error).toBeUndefined()
+    expect(result.status).toBe(0)
+  })
+
   it('maps every target stdio disposition and optional IPC channel', () => {
     expect(runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe'])
     expect(runnerStdio(spec({

+ 3 - 2
packages/subprocess/subprocess/src/index.ts

@@ -80,8 +80,9 @@ declare module '@deepseek-ai/cordis' {
  * Implementations must honor these semantics:
  * - Executable paths belong to one execution world shared with the mounted
  *   filesystem provider.
- * - {@link spawn} returns immediately with a live handle; `done` resolves at
- *   process close with exit facts and rejects only for spawn-level failures.
+ * - {@link spawn} returns a live handle synchronously after provider-specific
+ *   setup needed to publish its target pid. `done` resolves with direct-process
+ *   exit facts and may reject for spawn or selected provider-runner failures.
  * - Collect-mode readers are offset-based and non-consuming, so independent
  *   readers never consume one another's output; lossy reads report truncation
  *   and the spill file holding the complete stream when one exists. Piped

+ 3 - 0
pnpm-lock.yaml

@@ -4088,6 +4088,9 @@ importers:
       '@deepseek-ai/dsh-app-boot':
         specifier: workspace:^
         version: link:../../boot/app-boot
+      '@deepseek-ai/dsh-subprocess-local':
+        specifier: workspace:^
+        version: link:../../subprocess/subprocess-local
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^

+ 62 - 3
scripts/smoke-python-runtime.py

@@ -679,19 +679,26 @@ def main() -> None:
     parser = argparse.ArgumentParser(description=__doc__)
     parser.add_argument(
         "--scenario",
-        choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-mcp", "sdk-snapshot", "direct"),
+        choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-mcp", "sdk-snapshot", "runner", "direct"),
         default="all",
     )
     parser.add_argument("--exe", type=Path)
     parser.add_argument("--update-snapshots", action="store_true")
     args = parser.parse_args()
-    if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-snapshot", "direct"} and args.exe is None:
-        parser.error("--exe is required for custom, minimal, snapshot, and direct scenarios")
+    if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-snapshot", "runner", "direct"} and args.exe is None:
+        parser.error("--exe is required for custom, minimal, fs-search, snapshot, runner, and direct scenarios")
     if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-snapshot"}:
         parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-snapshot, or all")
     if args.exe is not None and not args.exe.is_file():
         parser.error(f"runtime executable does not exist: {args.exe}")
 
+    if args.scenario in {"all", "runner"}:
+        assert args.exe is not None
+        smoke_packaged_runner(args.exe.resolve())
+    if args.scenario == "runner":
+        print("smoke-python-runtime: runner passed")
+        return
+
     with MockModel() as model:
         if args.scenario in {"all", "sdk-default"}:
             smoke_sdk_default(model.url)
@@ -946,6 +953,58 @@ def smoke_direct(base_url: str, executable: Path) -> None:
         assert_session_log(sessions, root, EXPECTED_TEXT)
 
 
+def smoke_packaged_runner(executable: Path) -> None:
+    """Exercise the private subprocess runner through the single-file entry."""
+    with tempfile.TemporaryDirectory(prefix="dsh-packaged-runner-") as temporary:
+        root = Path(temporary).resolve()
+        request_path = root / "request.json"
+        events_path = root / "events.ndjson"
+        probe = subprocess.run(
+            [str(executable), "--dsh-internal-subprocess-runner", "--mode", "probe-node"],
+            cwd=root,
+            capture_output=True,
+            text=True,
+            timeout=30,
+            check=False,
+        )
+        if probe.returncode != 0:
+            raise AssertionError(f"packaged runner probe failed: {probe.stderr}")
+
+        request_path.write_text(json.dumps({
+            "argv": [sys.executable, "-c", "import sys; sys.exit(7)"],
+            "cwd": str(root),
+            "env": {},
+        }))
+        result = subprocess.run(
+            [
+                str(executable),
+                "--dsh-internal-subprocess-runner",
+                "--mode",
+                "node",
+                "--request",
+                str(request_path),
+                "--events",
+                str(events_path),
+            ],
+            cwd=root,
+            capture_output=True,
+            text=True,
+            timeout=30,
+            check=False,
+        )
+        if result.returncode != 7:
+            raise AssertionError(
+                f"packaged runner returned {result.returncode}, expected 7; stderr: {result.stderr}"
+            )
+        events = [json.loads(line) for line in events_path.read_text().splitlines()]
+        if len(events) != 2 or events[0].get("type") != "started" or events[1] != {
+            "type": "exit",
+            "exitCode": 7,
+            "signal": None,
+        }:
+            raise AssertionError(f"packaged runner emitted unexpected events: {events}")
+
+
 def is_idle_notification(message: dict[str, object]) -> bool:
     """Return whether a JSON-RPC notification marks a session idle."""
     params = message.get("params")

Some files were not shown because too many files changed in this diff