refactor(agent-core-v2): extract host process primitives into os/

Add App-scope IHostProcessService in os/interface with a node-local
backend, then make ISessionProcessRunner delegate to it instead of
calling child_process directly. Session/process keeps cwd/env overlay
semantics while os/ owns spawn/kill/cross-platform process-tree cleanup.

- add os/interface/hostProcess.ts and hostProcessService.ts
- update os/interface and os/backends barrels
- migrate session/process/processRunnerService.ts to inject IHostProcessService
- delete session/process/spawnedProcess.ts
- add tests for HostProcessService and update processRunnerService tests
This commit is contained in:
haozhe.yang 2026-07-03 12:17:50 +08:00
parent e7747c6a2f
commit 4d8dd8cd5c
9 changed files with 443 additions and 10 deletions

View file

@ -0,0 +1,197 @@
/**
* `hostProcess` domain (L6) `IHostProcessService` node-local implementation.
*
* Spawns child processes with `node:child_process.spawn`, wraps them in the
* domain-facing `IHostProcess` handle, and provides cross-platform process-tree
* termination. The service itself is stateless; each `spawn()` returns an
* independent handle that owns its streams and exit promise. Bound at App scope.
*/
import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process';
import type { Readable, Writable } from 'node:stream';
import { BufferedReadable } from '#/_base/execEnv';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import {
HostProcessError,
HostProcessErrorCode,
IHostProcessService,
type HostProcessOptions,
type IHostProcess,
} from '#/os/interface/hostProcess';
const isWindows: boolean = process.platform === 'win32';
function buildSpawnOptions(options: HostProcessOptions): SpawnOptions {
const detached = options.detached ?? !isWindows;
const spawnOptions: SpawnOptions = {
cwd: options.cwd,
env: buildEnv(options.env),
stdio: options.mergeStderr ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'],
detached,
windowsHide: options.windowsHide ?? true,
};
if (options.shell !== undefined) {
spawnOptions.shell = options.shell;
}
return spawnOptions;
}
function buildEnv(overrides: Record<string, string> | undefined): Record<string, string> | undefined {
if (overrides === undefined) {
return undefined;
}
return { ...(process.env as Record<string, string>), ...overrides };
}
function waitForSpawn(child: ChildProcess): Promise<void> {
return new Promise((resolve, reject) => {
const onSpawn = (): void => {
child.off('error', onError);
resolve();
};
const onError = (err: Error): void => {
child.off('spawn', onSpawn);
reject(err);
};
child.once('spawn', onSpawn);
child.once('error', onError);
});
}
class HostProcess implements IHostProcess {
declare readonly _serviceBrand: undefined;
readonly stdin: Writable;
readonly stdout: Readable;
readonly stderr: Readable;
readonly pid: number;
private readonly _child: ChildProcess;
private _exitCode: number | null = null;
private readonly _exitPromise: Promise<number>;
private _disposed = false;
constructor(child: ChildProcess, mergeStderr: boolean) {
if (child.stdin === null || child.stdout === null) {
throw new HostProcessError(
HostProcessErrorCode.SpawnFailed,
'Process must be created with stdin/stdout pipes.',
);
}
if (!mergeStderr && child.stderr === null) {
throw new HostProcessError(
HostProcessErrorCode.SpawnFailed,
'Process must be created with stderr pipe unless mergeStderr is set.',
);
}
this._child = child;
this.stdin = child.stdin;
this.stdout = new BufferedReadable(child.stdout);
this.stderr = mergeStderr
? this.stdout
: new BufferedReadable(child.stderr as Readable);
this.pid = child.pid ?? -1;
this._exitPromise = new Promise<number>((resolve, reject) => {
child.on('exit', (code: number | null) => {
this._exitCode = code ?? -1;
resolve(this._exitCode);
});
child.on('error', (error: Error) => {
reject(error);
});
});
}
get exitCode(): number | null {
return this._exitCode;
}
async wait(): Promise<number> {
return this._exitPromise;
}
async kill(signal?: NodeJS.Signals): Promise<void> {
if (this.pid <= 0) {
return;
}
if (isWindows) {
const taskkillArgs = ['/T', '/F', '/PID', String(this.pid)];
return new Promise<void>((resolve) => {
const killer = spawn('taskkill', taskkillArgs, {
stdio: 'ignore',
windowsHide: true,
});
const done = (): void => {
resolve();
};
killer.once('error', done);
killer.once('close', done);
});
}
try {
process.kill(-this.pid, signal ?? 'SIGTERM');
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === 'ESRCH') return;
if (err.code === 'EPERM') {
try {
this._child.kill(signal ?? 'SIGTERM');
} catch {
/* best effort */
}
return;
}
throw error;
}
}
dispose(): void {
if (this._disposed) return;
this._disposed = true;
this.stdin.destroy();
this.stdout.destroy();
if (this.stderr !== this.stdout) {
this.stderr.destroy();
}
}
}
export class HostProcessService implements IHostProcessService {
declare readonly _serviceBrand: undefined;
async spawn(
command: string,
args: readonly string[] = [],
options: HostProcessOptions = {},
): Promise<IHostProcess> {
const spawnOptions = buildSpawnOptions(options);
const child = spawn(command, args as string[], spawnOptions);
try {
await waitForSpawn(child);
} catch (error) {
const err = error as NodeJS.ErrnoException;
throw new HostProcessError(
HostProcessErrorCode.SpawnFailed,
`Failed to spawn "${command}": ${err.message}`,
);
}
return new HostProcess(child, options.mergeStderr ?? false);
}
}
registerScopedService(
LifecycleScope.App,
IHostProcessService,
HostProcessService,
InstantiationType.Delayed,
'hostProcess',
);

View file

@ -1,7 +1,5 @@
export * from './hostEnvironmentService';
export * from './hostFsService';
export * from './agentFsService';
export * from './processRunnerService';
export * from './hostProcessService';
export * from './terminalBackend';
export * from './terminalService';
export * from './folderBrowserService';

View file

@ -0,0 +1,84 @@
/**
* `hostProcess` domain (L1) the OS process-spawning contract.
*
* Defines `IHostProcessService`, the App-scope primitive used by any domain that
* needs to spawn a child process on the host, plus the `IHostProcess` handle it
* returns. The contract is deliberately close to Python `subprocess.Popen` /
* `os.spawn*`: a single `spawn()` call returns a handle exposing stdin/stdout/
* stderr, the pid, the exit code, and lifecycle methods. Bound at App scope;
* backends in `os/backends/node-local` provide the Node implementation.
*/
import type { Readable, Writable } from 'node:stream';
import { KimiError, type ErrorCode } from '#/_base/errors';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface HostProcessOptions {
/** Working directory for the child. Defaults to `process.cwd()`. */
readonly cwd?: string;
/** Complete env bag for the child. When omitted the child inherits `process.env`. */
readonly env?: Record<string, string>;
/**
* If `true`, the command is run through the system shell. If a string, it is
* used as the shell path. Mirrors Python `subprocess.run(..., shell=True)`.
*/
readonly shell?: boolean | string;
/**
* Whether the child becomes a process-group leader. Default is `true` on
* POSIX and `false` on Windows so that `kill()` can signal the whole tree.
*/
readonly detached?: boolean;
/** Hide the child window on Windows. Default `true`. */
readonly windowsHide?: boolean;
/** Redirect stderr into stdout (the child still gets a merged stream). */
readonly mergeStderr?: boolean;
/** Optional timeout in milliseconds for `wait()`. */
readonly timeout?: number;
}
export interface IHostProcess {
readonly _serviceBrand: undefined;
readonly pid: number;
readonly exitCode: number | null;
readonly stdin: Writable;
readonly stdout: Readable;
readonly stderr: Readable;
/** Wait for the process to exit and return its exit code. */
wait(): Promise<number>;
/** Kill the process tree (not just the direct child) with the given signal. */
kill(signal?: NodeJS.Signals): Promise<void>;
/** Release stdio streams. Does not kill the process. */
dispose(): void;
}
export interface IHostProcessService {
readonly _serviceBrand: undefined;
/**
* Spawn a child process on the host. Resolves once the child has successfully
* started (or rejects with a coded error if spawn fails with ENOENT / EACCES
* / etc.).
*/
spawn(
command: string,
args?: readonly string[],
options?: HostProcessOptions,
): Promise<IHostProcess>;
}
export const IHostProcessService: ServiceIdentifier<IHostProcessService> =
createDecorator<IHostProcessService>('hostProcessService');
export const HostProcessErrorCode = {
SpawnFailed: 'process.spawn_failed' as ErrorCode,
} as const;
export class HostProcessError extends KimiError {
constructor(code: (typeof HostProcessErrorCode)[keyof typeof HostProcessErrorCode], message: string) {
super(code, message);
this.name = 'HostProcessError';
}
}

View file

@ -1,8 +1,5 @@
export * from './hostEnvironment';
export * from './execContext';
export * from './fileSystem';
export * from './hostFileSystem';
export * from './process';
export * from './hostProcess';
export * from './terminal';
export * from './terminalErrors';
export * from './folderBrowser';

View file

@ -1,6 +1,7 @@
/**
* `process` domain barrel compatibility re-export.
* `process` domain barrel re-exports the session process runner contract
* and its scoped implementation.
*/
export * from '#/os/interface/process';
export * from '#/os/backends/node-local/processRunnerService';
export * from './processRunner';
export * from './processRunnerService';

View file

@ -0,0 +1,72 @@
/**
* `process` domain (L2) `ISessionProcessRunner` implementation.
*
* Resolves cwd + env from the session's `IExecContext` and delegates the actual
* host spawn to the App-scope `IHostProcessService`. Per-call overrides
* (`options.cwd`, `options.env`) win over the seeded context; env layers are
* overlaid onto `process.env` in registration order, then the caller-supplied
* env goes on top. When neither `envLayers` nor `options.env` is set we pass
* `undefined` so the child inherits `process.env` verbatim. Bound at Session
* scope.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IHostProcessService } from '#/os/interface/hostProcess';
import { IExecContext } from '#/session/execContext';
import { type IProcess, ISessionProcessRunner, type ProcessExecOptions } from './processRunner';
export class SessionProcessRunner implements ISessionProcessRunner {
declare readonly _serviceBrand: undefined;
constructor(
@IExecContext private readonly ctx: IExecContext,
@IHostProcessService private readonly hostProcess: IHostProcessService,
) {}
async exec(args: readonly string[], options?: ProcessExecOptions): Promise<IProcess> {
const command = args[0];
if (command === undefined) {
throw new Error(
'SessionProcessRunner.exec(): at least one argument (the command to run) is required.',
);
}
const restArgs = args.slice(1);
const cwd = options?.cwd ?? this.ctx.cwd;
const env = this._buildExecEnv(options?.env);
return this.hostProcess.spawn(command, restArgs, { cwd, env });
}
private _buildExecEnv(
invocationEnv: Record<string, string> | undefined,
): Record<string, string> | undefined {
// No overrides at all — inherit process.env verbatim by passing `undefined`
// to the host process service. Mirrors the pre-refactor behaviour when
// neither the session context nor the caller wanted to touch the child's
// environment.
if (this.ctx.envLayers.length === 0 && invocationEnv === undefined) {
return undefined;
}
const merged: Record<string, string> = {
...(process.env as Record<string, string>),
};
for (const layer of this.ctx.envLayers) {
Object.assign(merged, layer);
}
if (invocationEnv !== undefined) {
Object.assign(merged, invocationEnv);
}
return merged;
}
}
registerScopedService(
LifecycleScope.Session,
ISessionProcessRunner,
SessionProcessRunner,
InstantiationType.Delayed,
'process',
);

View file

@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { Readable } from 'node:stream';
import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices, type TestInstantiationService } from '#/_base/di/test';
import {
HostProcessError,
HostProcessErrorCode,
IHostProcessService,
} from '#/os/interface/hostProcess';
import { HostProcessService } from '#/os/backends/node-local/hostProcessService';
async function collect(stream: Readable): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(chunk as Buffer);
}
return Buffer.concat(chunks).toString('utf8');
}
describe('HostProcessService', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
beforeEach(() => {
disposables = new DisposableStore();
ix = createServices(disposables, {
additionalServices: (reg) => {
reg.define(IHostProcessService, HostProcessService);
},
});
});
afterEach(() => {
disposables.dispose();
});
it('spawns a process and captures stdout + exit code', async () => {
const svc = ix.get(IHostProcessService);
const proc = await svc.spawn('node', ['-e', 'process.stdout.write("ok")']);
const out = await collect(proc.stdout);
expect(out).toBe('ok');
expect(await proc.wait()).toBe(0);
expect(proc.exitCode).toBe(0);
});
it('passes env overrides to the child', async () => {
const svc = ix.get(IHostProcessService);
const proc = await svc.spawn('node', ['-e', 'process.stdout.write(process.env.FOO ?? "")'], {
env: { FOO: 'bar' },
});
const out = await collect(proc.stdout);
expect(out).toBe('bar');
expect(await proc.wait()).toBe(0);
});
it('throws a coded error when the command does not exist', async () => {
const svc = ix.get(IHostProcessService);
await expect(svc.spawn('definitely-not-a-real-command-42')).rejects.toSatisfy((err: unknown) => {
expect(err).toBeInstanceOf(HostProcessError);
expect((err as HostProcessError).code).toBe(HostProcessErrorCode.SpawnFailed);
return true;
});
});
it('terminates a running process with kill()', async () => {
const svc = ix.get(IHostProcessService);
const proc = await svc.spawn('node', ['-e', 'setTimeout(() => {}, 30000)']);
expect(proc.pid).toBeGreaterThan(0);
await proc.kill('SIGTERM');
const code = await proc.wait();
expect(code).not.toBe(0);
});
});

View file

@ -12,6 +12,8 @@ import {
registerScopedService,
} from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
import { IHostProcessService } from '#/os/interface/hostProcess';
import { HostProcessService } from '#/os/backends/node-local/hostProcessService';
import { createExecContext, IExecContext } from '#/session/execContext';
import { ISessionProcessRunner, SessionProcessRunner } from '#/session/process';
@ -28,6 +30,13 @@ describe('SessionProcessRunner (backed by IExecContext)', () => {
beforeEach(async () => {
_clearScopedRegistryForTests();
registerScopedService(
LifecycleScope.App,
IHostProcessService,
HostProcessService,
InstantiationType.Delayed,
'hostProcess',
);
registerScopedService(
LifecycleScope.Session,
ISessionProcessRunner,