refactor(runtime): eliminate local agent process management and CLI probing

- Refactor MultiAgentOrchestrator to remove local process management.
- Disable local CLI execution and existence checks in orchestration workflows.
- Remove local process discovery and state reconciliation in mount adapters.
- Mark local agent process launching as deprecated/disabled across runtime.
This commit is contained in:
Haitao Pan 2026-04-21 12:45:26 +08:00
parent d3da1505f6
commit c214594fa1
4 changed files with 45 additions and 495 deletions

View File

@ -302,49 +302,8 @@ class CodexRuntime extends ChangeNotifier {
CodexAccount? get account => _account;
Stream<CodexEvent> get events => _events.stream;
/// Find Codex binary in PATH or common locations.
Future<String?> findCodexBinary() async {
// Check environment variable first
final envPath = Platform.environment['CODEX_PATH'];
if (envPath != null && envPath.isNotEmpty) {
final file = File(envPath);
if (await file.exists()) {
return envPath;
}
}
// Try common locations
final paths = defaultCodexBinaryCandidates();
for (final path in paths) {
final file = File(path);
if (await file.exists()) {
return path;
}
}
// Try to find via platform-native lookup.
try {
final result = await Process.run(
_lookupExecutableProgram(),
_lookupExecutableArguments(),
);
if (result.exitCode == 0) {
final lines = LineSplitter.split(
result.stdout as String,
).map((line) => line.trim()).where((line) => line.isNotEmpty);
for (final path in lines) {
if (await File(path).exists()) {
return path;
}
}
}
} catch (_) {
// Ignore
}
return null;
}
/// Find Codex binary (DEPRECATED: Use bridge instead).
Future<String?> findCodexBinary() async => null;
/// Start Codex App Server in stdio mode (DEPRECATED: Use bridge instead).
Future<void> startStdio({

View File

@ -6,6 +6,9 @@ import 'multi_agent_mount_resolver.dart';
import 'opencode_config_bridge.dart';
import 'runtime_models.dart';
///
///
///
class MultiAgentMountManager {
MultiAgentMountManager({
CodexConfigBridge? codexConfigBridge,
@ -62,14 +65,13 @@ class MultiAgentMountManager {
}
Future<ArisMountProbe> _buildArisProbe() async {
// ARIS is legacy and has been removed from assets.
return const ArisMountProbe(
available: false,
bundleVersion: '',
llmChatServerPath: '',
skillCount: 0,
bridgeAvailable: false,
error: 'ARIS has been removed from application assets.',
error: 'Legacy local agent execution is disabled.',
);
}
@ -79,29 +81,12 @@ class MultiAgentMountManager {
}) async {
final states = <ManagedMountTargetState>[];
for (final adapter in _adapters) {
try {
states.add(
await adapter.reconcile(
config: config,
aiGatewayUrl: aiGatewayUrl,
),
);
} catch (error) {
states.add(
ManagedMountTargetState.placeholder(
targetId: adapter.targetId,
label: adapter.label,
supportsSkills: adapter.supportsSkills,
supportsMcp: adapter.supportsMcp,
supportsAiGatewayInjection: adapter.supportsAiGatewayInjection,
).copyWith(
available: await adapter.isInstalled(),
discoveryState: 'error',
syncState: 'error',
detail: error.toString(),
),
);
}
states.add(
await adapter.reconcile(
config: config,
aiGatewayUrl: aiGatewayUrl,
),
);
}
return config.copyWith(
mountTargets: states,
@ -125,36 +110,6 @@ abstract class CliMountAdapter {
required String aiGatewayUrl,
});
Future<String> _runCommand(List<String> command) async {
final result = await Process.run(
command.first,
command.sublist(1),
runInShell: true,
);
final stdout = '${result.stdout}'.trim();
final stderr = '${result.stderr}'.trim();
return stdout.isNotEmpty ? stdout : stderr;
}
Future<int> _countListedEntries(List<String> command) async {
final output = await _runCommand(command);
if (output.isEmpty ||
output.contains('No MCP servers configured') ||
output.contains('No MCP servers configured yet') ||
output.contains('No MCP servers configured.')) {
return 0;
}
return output
.split('\n')
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.where((item) => !item.startsWith('Usage:'))
.where((item) => !item.startsWith(''))
.where((item) => !item.startsWith(''))
.where((item) => !item.startsWith(''))
.length;
}
int countMcpTomlSections(String content) {
return RegExp(
r'^\[mcp_servers\.[^\]]+\]',
@ -191,28 +146,6 @@ class CodexMountAdapter extends CliMountAdapter {
required MultiAgentConfig config,
required String aiGatewayUrl,
}) async {
final available = await isInstalled();
final configFile = File('${_bridge.codexHome}/config.toml');
final content = await configFile.exists()
? await configFile.readAsString()
: '';
final discoveredMcpCount = countMcpTomlSections(content);
final managedMcpServers = config.managedMcpServers
.where((item) => item.enabled && item.command.trim().isNotEmpty)
.toList(growable: false);
if (available && config.autoSync && managedMcpServers.isNotEmpty) {
await _bridge.configureManagedMcpServers(
servers: managedMcpServers
.map(
(item) => CodexMcpServer(
name: item.id,
command: item.command,
args: item.args,
),
)
.toList(growable: false),
);
}
return ManagedMountTargetState.placeholder(
targetId: targetId,
label: label,
@ -220,18 +153,10 @@ class CodexMountAdapter extends CliMountAdapter {
supportsMcp: supportsMcp,
supportsAiGatewayInjection: supportsAiGatewayInjection,
).copyWith(
available: available,
discoveryState: available ? 'ready' : 'missing',
syncState: !available
? 'missing'
: config.autoSync
? 'ready'
: 'disabled',
discoveredMcpCount: discoveredMcpCount,
managedMcpCount: managedMcpServers.length,
detail: aiGatewayUrl.isNotEmpty
? 'LLM API uses launch-scoped defaults for collaboration runs.'
: 'LLM API not configured.',
available: false,
discoveryState: 'missing',
syncState: 'missing',
detail: 'Local CLI interaction is disabled. Use bridge for orchestration.',
);
}
}
@ -260,10 +185,6 @@ class ClaudeMountAdapter extends CliMountAdapter {
required MultiAgentConfig config,
required String aiGatewayUrl,
}) async {
final available = await isInstalled();
final discoveredMcpCount = available
? await _countListedEntries(<String>['claude', 'mcp', 'list'])
: 0;
return ManagedMountTargetState.placeholder(
targetId: targetId,
label: label,
@ -271,15 +192,10 @@ class ClaudeMountAdapter extends CliMountAdapter {
supportsMcp: supportsMcp,
supportsAiGatewayInjection: supportsAiGatewayInjection,
).copyWith(
available: available,
discoveryState: available ? 'ready' : 'missing',
syncState: available && config.autoSync ? 'launch-only' : 'disabled',
discoveredMcpCount: discoveredMcpCount,
managedMcpCount: config.managedMcpServers
.where((item) => item.enabled)
.length,
detail:
'MCP discovery uses `claude mcp list`; LLM API stays launch-scoped.',
available: false,
discoveryState: 'missing',
syncState: 'disabled',
detail: 'Local CLI interaction is disabled.',
);
}
}
@ -308,10 +224,6 @@ class GeminiMountAdapter extends CliMountAdapter {
required MultiAgentConfig config,
required String aiGatewayUrl,
}) async {
final available = await isInstalled();
final discoveredMcpCount = available
? await _countListedEntries(<String>['gemini', 'mcp', 'list'])
: 0;
return ManagedMountTargetState.placeholder(
targetId: targetId,
label: label,
@ -319,15 +231,10 @@ class GeminiMountAdapter extends CliMountAdapter {
supportsMcp: supportsMcp,
supportsAiGatewayInjection: supportsAiGatewayInjection,
).copyWith(
available: available,
discoveryState: available ? 'ready' : 'missing',
syncState: available && config.autoSync ? 'launch-only' : 'disabled',
discoveredMcpCount: discoveredMcpCount,
managedMcpCount: config.managedMcpServers
.where((item) => item.enabled)
.length,
detail:
'MCP discovery uses `gemini mcp list`; LLM API stays launch-scoped.',
available: false,
discoveryState: 'missing',
syncState: 'disabled',
detail: 'Local CLI interaction is disabled.',
);
}
}
@ -360,26 +267,6 @@ class OpencodeMountAdapter extends CliMountAdapter {
required MultiAgentConfig config,
required String aiGatewayUrl,
}) async {
final available = await isInstalled();
final content = await _bridge.readConfig();
final discoveredMcpCount = countMcpTomlSections(content);
final managedMcpServers = config.managedMcpServers
.where((item) => item.enabled)
.toList(growable: false);
if (available && config.autoSync && managedMcpServers.isNotEmpty) {
await _bridge.configureManagedMcpServers(
servers: managedMcpServers
.map(
(item) => OpencodeMcpServer(
name: item.id,
command: item.command,
url: item.url,
args: item.args,
),
)
.toList(growable: false),
);
}
return ManagedMountTargetState.placeholder(
targetId: targetId,
label: label,
@ -387,16 +274,10 @@ class OpencodeMountAdapter extends CliMountAdapter {
supportsMcp: supportsMcp,
supportsAiGatewayInjection: supportsAiGatewayInjection,
).copyWith(
available: available,
discoveryState: available ? 'ready' : 'missing',
syncState: !available
? 'missing'
: config.autoSync
? 'ready'
: 'disabled',
discoveredMcpCount: discoveredMcpCount,
managedMcpCount: managedMcpServers.length,
detail: 'Managed MCP config is preserved in ~/.opencode/config.toml.',
available: false,
discoveryState: 'missing',
syncState: 'missing',
detail: 'Local CLI interaction is disabled.',
);
}
}
@ -425,36 +306,6 @@ class OpenClawMountAdapter extends CliMountAdapter {
required MultiAgentConfig config,
required String aiGatewayUrl,
}) async {
final available = await isInstalled();
final configFile = File(
'${Platform.environment['HOME'] ?? ''}/.openclaw/openclaw.json',
);
var discoveredSkillCount = 0;
var detail = 'OpenClaw acts as the host/control plane mount.';
if (await configFile.exists()) {
try {
final decoded = jsonDecode(await configFile.readAsString());
final agents =
(decoded is Map<String, dynamic> &&
decoded['agents'] is Map<String, dynamic> &&
(decoded['agents'] as Map<String, dynamic>)['list'] is List)
? ((decoded['agents'] as Map<String, dynamic>)['list'] as List)
.length
: 0;
final skillsDir = Directory(
'${Platform.environment['HOME'] ?? ''}/.openclaw/skills',
);
if (await skillsDir.exists()) {
discoveredSkillCount = await skillsDir
.list()
.where((entity) => entity is File || entity is Directory)
.length;
}
detail = 'agents: $agents · skills: $discoveredSkillCount';
} catch (_) {
detail = 'OpenClaw config detected but could not be fully parsed.';
}
}
return ManagedMountTargetState.placeholder(
targetId: targetId,
label: label,
@ -462,11 +313,10 @@ class OpenClawMountAdapter extends CliMountAdapter {
supportsMcp: supportsMcp,
supportsAiGatewayInjection: supportsAiGatewayInjection,
).copyWith(
available: available,
discoveryState: available ? 'ready' : 'missing',
syncState: available && config.autoSync ? 'launch-only' : 'disabled',
discoveredSkillCount: discoveredSkillCount,
detail: detail,
available: false,
discoveryState: 'missing',
syncState: 'disabled',
detail: 'Local CLI interaction is disabled.',
);
}
}

View File

@ -14,39 +14,21 @@ import 'multi_agent_orchestrator_support.dart';
/// Agent
///
/// Architect/ Lead Engineer Worker/Review worker +
/// Ollama CLI
///
///
///
/// - Architect/
/// - Lead Engineer
/// - Worker/Review worker
/// ACP
class MultiAgentOrchestrator extends ChangeNotifier {
MultiAgentOrchestrator({
required MultiAgentConfig config,
Future<bool> Function(String command)? binaryExistsResolver,
HttpClient Function()? httpClientFactory,
CliProcessStarter? processStarter,
}) : configInternal = config,
binaryExistsResolverInternal = binaryExistsResolver,
httpClientFactoryInternal = httpClientFactory ?? HttpClient.new,
processStarterInternal =
processStarter ??
((executable, arguments, {environment, workingDirectory}) {
return Process.start(
executable,
arguments,
environment: environment,
workingDirectory: workingDirectory,
);
});
httpClientFactoryInternal = httpClientFactory ?? HttpClient.new;
///
MultiAgentConfig configInternal;
MultiAgentConfig get config => configInternal;
final Future<bool> Function(String command)? binaryExistsResolverInternal;
final HttpClient Function() httpClientFactoryInternal;
final CliProcessStarter processStarterInternal;
Process? activeCliProcessInternal;
HttpClient? activeHttpClientInternal;
bool abortRequestedInternal = false;
@ -80,15 +62,6 @@ class MultiAgentOrchestrator extends ChangeNotifier {
Future<void> abort() async {
abortRequestedInternal = true;
final process = activeCliProcessInternal;
activeCliProcessInternal = null;
if (process != null) {
try {
process.kill();
} catch (_) {
// Best effort only.
}
}
final client = activeHttpClientInternal;
activeHttpClientInternal = null;
if (client != null) {
@ -100,16 +73,6 @@ class MultiAgentOrchestrator extends ChangeNotifier {
}
}
void assertEmbeddedProcessesAllowedInternal() {
if (shouldBlockEmbeddedAgentLaunch(
isAppleHost: Platform.isIOS || Platform.isMacOS,
)) {
throw UnsupportedError(
'App Store builds do not allow launching embedded multi-agent subprocesses.',
);
}
}
///
void enable() {
configInternal = configInternal.copyWith(enabled: true);
@ -135,8 +98,6 @@ class MultiAgentOrchestrator extends ChangeNotifier {
}
///
///
/// Architect Engineer Tester
Future<CollaborationResult> runCollaboration({
required String taskPrompt,
required String workingDirectory,
@ -144,7 +105,6 @@ class MultiAgentOrchestrator extends ChangeNotifier {
List<String> selectedSkills = const [],
void Function(MultiAgentRunEvent event)? onEvent,
}) async {
assertEmbeddedProcessesAllowedInternal();
if (isRunningInternal) {
throw StateError('Collaboration is already running');
}

View File

@ -269,7 +269,7 @@ $originalCode
);
}
/// CLI
/// CLI (DEPRECATED: Use bridge instead)
Future<CliResult> runCliPromptInternal({
required MultiAgentRole role,
required String tool,
@ -277,167 +277,9 @@ $originalCode
required String prompt,
required String cwd,
}) async {
late final List<String> args;
late final String command;
late final Map<String, String> envVars;
final useOllamaLaunch = prefersOllamaLaunchInternal(
tool: tool,
model: model,
);
switch (tool) {
case 'claude':
command = useOllamaLaunch ? 'ollama' : resolveCliPathInternal('claude');
envVars = buildCliEnvVarsInternal(tool: tool);
if (useOllamaLaunch) {
args = buildOllamaLaunchArgsInternal(
tool: tool,
model: model,
prompt: prompt,
cwd: cwd,
);
} else if (model.isNotEmpty) {
args = ['--model', model, '-p', prompt];
} else {
args = ['-p', prompt];
}
break;
case 'codex':
command = useOllamaLaunch ? 'ollama' : resolveCliPathInternal('codex');
envVars = buildCliEnvVarsInternal(tool: tool);
if (useOllamaLaunch) {
args = buildOllamaLaunchArgsInternal(
tool: tool,
model: model,
prompt: prompt,
cwd: cwd,
);
} else if (model.isNotEmpty) {
args = [
'exec',
'--skip-git-repo-check',
'--color',
'never',
if (cwd.isNotEmpty) ...['-C', cwd],
'-m',
model,
prompt,
];
} else {
args = [
'exec',
'--skip-git-repo-check',
'--color',
'never',
if (cwd.isNotEmpty) ...['-C', cwd],
prompt,
];
}
break;
case 'gemini':
command = resolveCliPathInternal('gemini');
envVars = buildCliEnvVarsInternal(tool: tool);
if (model.isNotEmpty) {
args = ['--model', model, '-p', prompt];
} else {
args = ['-p', prompt];
}
break;
case 'opencode':
command = useOllamaLaunch
? 'ollama'
: resolveCliPathInternal('opencode');
envVars = buildCliEnvVarsInternal(tool: tool);
args = useOllamaLaunch
? buildOllamaLaunchArgsInternal(
tool: tool,
model: model,
prompt: prompt,
cwd: cwd,
)
: [
'run',
'--format',
'default',
if (cwd.isNotEmpty) ...['--dir', cwd],
if (model.isNotEmpty) ...['-m', model],
prompt,
];
break;
default:
throw ArgumentError('Unknown tool: $tool');
}
final cliAvailable = await binaryExistsInternal(command);
if (configInternal.usesAris && !cliAvailable) {
return runArisFallbackInternal(role: role, model: model, prompt: prompt);
}
try {
final process = await processStarterInternal(
command,
args,
environment: envVars,
workingDirectory: cwd.isNotEmpty ? cwd : null,
);
activeCliProcessInternal = process;
await process.stdin.close();
//
final timeout = Duration(seconds: configInternal.timeoutSeconds);
final stdoutFuture = process.stdout
.transform(utf8.decoder)
.join()
.timeout(
timeout,
onTimeout: () {
process.kill();
return '[超时或进程已终止]';
},
);
final stderrFuture = process.stderr
.transform(utf8.decoder)
.join()
.timeout(timeout, onTimeout: () => '');
final results = await Future.wait([stdoutFuture, stderrFuture]);
final exitCode = await process.exitCode.timeout(
timeout,
onTimeout: () => -1,
);
activeCliProcessInternal = null;
final cliResult = CliResult(
output: results[0],
error: results[1],
exitCode: exitCode,
);
if (configInternal.usesAris && !cliResult.success) {
return runArisFallbackInternal(
role: role,
model: model,
prompt: prompt,
);
}
return cliResult;
} catch (e) {
activeCliProcessInternal = null;
if (configInternal.usesAris) {
return runArisFallbackInternal(
role: role,
model: model,
prompt: prompt,
);
}
return CliResult(output: '', error: e.toString(), exitCode: -1);
}
// In cloud-neutral architecture, local CLI execution is disabled.
// We should fallback to OpenAI compatible API or bridge execution.
return runArisFallbackInternal(role: role, model: model, prompt: prompt);
}
/// Architect Prompt
@ -481,49 +323,6 @@ ${selectedSkills.isEmpty ? '- 无' : selectedSkills.map((item) => '- $item').joi
MultiAgentRole role,
String configuredTool,
) async {
if (!configInternal.usesAris) {
return configuredTool;
}
final configuredModel = resolvedModelForRoleInternal(
role,
configuredModel: modelForRoleInternal(role).trim(),
);
final candidates = switch (role) {
MultiAgentRole.architect => <String>[
configuredTool,
'claude',
'codex',
'opencode',
'gemini',
],
MultiAgentRole.engineer => <String>[
configuredTool,
'codex',
'opencode',
'claude',
'gemini',
],
MultiAgentRole.testerDoc => <String>[
configuredTool,
'opencode',
'codex',
'claude',
'gemini',
],
};
for (final candidate in candidates) {
final trimmed = candidate.trim();
if (trimmed.isEmpty) {
continue;
}
if (prefersOllamaLaunchInternal(tool: trimmed, model: configuredModel)) {
if (await binaryExistsInternal('ollama')) {
return trimmed;
}
} else if (await binaryExistsInternal(resolveCliPathInternal(trimmed))) {
return trimmed;
}
}
return configuredTool;
}
@ -546,16 +345,7 @@ ${selectedSkills.isEmpty ? '- 无' : selectedSkills.map((item) => '- $item').joi
}
Future<bool> binaryExistsInternal(String command) async {
final resolver = binaryExistsResolverInternal;
if (resolver != null) {
return resolver(command);
}
final check = await Process.run(
Platform.isWindows ? 'where' : 'which',
<String>[command],
runInShell: true,
);
return check.exitCode == 0 && '${check.stdout}'.trim().isNotEmpty;
return false;
}
Future<CliResult> runArisFallbackInternal({
@ -594,19 +384,10 @@ ${selectedSkills.isEmpty ? '- 无' : selectedSkills.map((item) => '- $item').joi
required String model,
required String prompt,
}) async {
if (await binaryExistsInternal(resolveCliPathInternal('claude'))) {
return runCliPromptInternal(
role: MultiAgentRole.testerDoc,
tool: 'claude',
model: model,
prompt: prompt,
cwd: '',
);
}
return CliResult(
output: '',
error: 'Claude CLI is unavailable for claude-review',
exitCode: -1,
return runArisFallbackInternal(
role: MultiAgentRole.testerDoc,
model: model,
prompt: prompt,
);
}