mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
IJPL-233558 fix codex process creation
GitOrigin-RevId: 45edbc60fd75dd9dbe989a814fa297da036bee20
This commit is contained in:
committed by
intellij-monorepo-bot
parent
afd381f6a2
commit
838769ce6e
@@ -3,7 +3,7 @@ package com.intellij.agent.workbench.codex.common
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.intellij.execution.configurations.PathEnvironmentVariableUtil
|
||||
import com.intellij.execution.configurations.GeneralCommandLine
|
||||
import com.intellij.openapi.diagnostic.debug
|
||||
import com.intellij.openapi.diagnostic.logger
|
||||
import com.intellij.util.io.awaitExit
|
||||
@@ -20,16 +20,21 @@ import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.io.BufferedReader
|
||||
import java.io.BufferedWriter
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.NoSuchFileException
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
private const val CODEX_COMMAND = "codex"
|
||||
private const val REQUEST_TIMEOUT_MS = 30_000L
|
||||
private const val PROCESS_TERMINATION_TIMEOUT_MS = 2_000L
|
||||
private const val MAX_PAGES = 10
|
||||
private const val PAGE_LIMIT = 50
|
||||
|
||||
@@ -37,18 +42,17 @@ private val LOG = logger<CodexAppServerClient>()
|
||||
|
||||
class CodexAppServerClient(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val executablePathProvider: () -> String? = {
|
||||
PathEnvironmentVariableUtil.findExecutableInPathOnAnyOS(CODEX_COMMAND)?.absolutePath
|
||||
},
|
||||
private val executablePathProvider: () -> String? = { null },
|
||||
private val environmentOverrides: Map<String, String> = emptyMap(),
|
||||
private val workingDirectory: Path? = null,
|
||||
workingDirectory: Path? = null,
|
||||
) {
|
||||
private val pending = ConcurrentHashMap<String, CompletableDeferred<String>>()
|
||||
private val requestCounter = AtomicLong(0)
|
||||
private val writeMutex = Mutex()
|
||||
private val startMutex = Mutex()
|
||||
private val initMutex = Mutex()
|
||||
private val protocol = CodexAppServerProtocol(workingDirectory)
|
||||
private val workingDirectoryPath = workingDirectory
|
||||
private val protocol = CodexAppServerProtocol(workingDirectoryPath)
|
||||
|
||||
@Volatile
|
||||
private var process: Process? = null
|
||||
@@ -152,7 +156,7 @@ class CodexAppServerClient(
|
||||
pending[id] = deferred
|
||||
try {
|
||||
sendRequest(id, method, paramsWriter)
|
||||
val response = withTimeout(REQUEST_TIMEOUT_MS) { deferred.await() }
|
||||
val response = withTimeout(REQUEST_TIMEOUT_MS.milliseconds) { deferred.await() }
|
||||
return protocol.parseResponse(response, resultParser, defaultResult)
|
||||
}
|
||||
catch (t: TimeoutCancellationException) {
|
||||
@@ -250,26 +254,27 @@ class CodexAppServerClient(
|
||||
}
|
||||
|
||||
private fun startProcess(): Process {
|
||||
val executable = executablePathProvider() ?: throw CodexCliNotFoundException()
|
||||
val configuredExecutable = executablePathProvider()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
val executable = configuredExecutable ?: CODEX_COMMAND
|
||||
val process = try {
|
||||
ProcessBuilder(executable, "app-server").apply {
|
||||
if (environmentOverrides.isNotEmpty()) {
|
||||
val env = environment()
|
||||
for ((key, value) in environmentOverrides) {
|
||||
env[key] = value
|
||||
GeneralCommandLine(executable, "app-server")
|
||||
.withParentEnvironmentType(GeneralCommandLine.ParentEnvironmentType.CONSOLE)
|
||||
.withEnvironment(environmentOverrides)
|
||||
.apply {
|
||||
val directory = workingDirectoryPath
|
||||
if (directory != null && Files.isDirectory(directory)) {
|
||||
withWorkingDirectory(directory)
|
||||
}
|
||||
}
|
||||
val directory = workingDirectory
|
||||
if (directory != null && Files.isDirectory(directory)) {
|
||||
@Suppress("IO_FILE_USAGE")
|
||||
directory(directory.toFile())
|
||||
}
|
||||
}
|
||||
.redirectErrorStream(false)
|
||||
.start()
|
||||
.createProcess()
|
||||
}
|
||||
catch (t: Throwable) {
|
||||
throw CodexAppServerException("Failed to start Codex app-server", t)
|
||||
if (configuredExecutable == null && isExecutableNotFound(t)) {
|
||||
throw CodexCliNotFoundException()
|
||||
}
|
||||
throw CodexAppServerException("Failed to start Codex app-server from $executable", t)
|
||||
}
|
||||
this.process = process
|
||||
this.writer = BufferedWriter(OutputStreamWriter(process.outputStream, StandardCharsets.UTF_8))
|
||||
@@ -400,10 +405,34 @@ class CodexAppServerClient(
|
||||
stderrJob?.cancel()
|
||||
waitJob?.cancel()
|
||||
current.destroy()
|
||||
try {
|
||||
if (!current.waitFor(PROCESS_TERMINATION_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
|
||||
current.destroyForcibly()
|
||||
current.waitFor(PROCESS_TERMINATION_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
}
|
||||
catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun isExecutableNotFound(error: Throwable): Boolean {
|
||||
return generateSequence(error) { it.cause }
|
||||
.any { cause ->
|
||||
when (cause) {
|
||||
is NoSuchFileException -> true
|
||||
is IOException -> {
|
||||
val message = cause.message ?: return@any false
|
||||
message.contains("error=2") ||
|
||||
message.contains("no such file or directory", ignoreCase = true) ||
|
||||
message.contains("cannot find the file", ignoreCase = true)
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
open class CodexAppServerException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
|
||||
|
||||
class CodexCliNotFoundException : CodexAppServerException("Codex CLI not found")
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.intellij.agent.workbench.sessions
|
||||
|
||||
import com.intellij.agent.workbench.codex.common.CodexAppServerClient
|
||||
import com.intellij.agent.workbench.codex.common.CodexAppServerException
|
||||
import com.intellij.agent.workbench.codex.common.CodexCliNotFoundException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
@@ -387,4 +389,99 @@ class CodexAppServerClientTest {
|
||||
client.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listThreadsUsesPathOverrideWhenExecutableProviderMissing(): Unit = runBlocking(Dispatchers.IO) {
|
||||
val workingDir = tempDir.resolve("project-path-override")
|
||||
Files.createDirectories(workingDir)
|
||||
val configPath = workingDir.resolve("codex-config.json")
|
||||
writeConfig(
|
||||
path = configPath,
|
||||
threads = listOf(
|
||||
ThreadSpec(
|
||||
id = "thread-path",
|
||||
title = "Path Thread",
|
||||
cwd = workingDir.toString(),
|
||||
updatedAt = 1_700_000_000_000L,
|
||||
archived = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val backendDir = tempDir.resolve("backend-path-override")
|
||||
Files.createDirectories(backendDir)
|
||||
val codexShim = createMockCodexShim(backendDir, configPath)
|
||||
val client = CodexAppServerClient(
|
||||
coroutineScope = this,
|
||||
executablePathProvider = { null },
|
||||
environmentOverrides = mapOf("PATH" to codexShim.parent.toString()),
|
||||
workingDirectory = workingDir,
|
||||
)
|
||||
try {
|
||||
val threads = client.listThreads(archived = false)
|
||||
assertThat(threads.map { it.id }).containsExactly("thread-path")
|
||||
}
|
||||
finally {
|
||||
client.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listThreadsFailsWithoutFallbackWhenConfiguredExecutableIsInvalid(): Unit = runBlocking(Dispatchers.IO) {
|
||||
val workingDir = tempDir.resolve("project-invalid-exec")
|
||||
Files.createDirectories(workingDir)
|
||||
val configPath = workingDir.resolve("codex-config.json")
|
||||
writeConfig(path = configPath, threads = emptyList())
|
||||
|
||||
val backendDir = tempDir.resolve("backend-invalid-exec")
|
||||
Files.createDirectories(backendDir)
|
||||
val codexShim = createMockCodexShim(backendDir, configPath)
|
||||
val invalidExecutable = tempDir.resolve("missing-codex").toString()
|
||||
val client = CodexAppServerClient(
|
||||
coroutineScope = this,
|
||||
executablePathProvider = { invalidExecutable },
|
||||
environmentOverrides = mapOf("PATH" to codexShim.parent.toString()),
|
||||
workingDirectory = workingDir,
|
||||
)
|
||||
try {
|
||||
try {
|
||||
client.listThreads(archived = false)
|
||||
fail("Expected CodexAppServerException")
|
||||
}
|
||||
catch (e: CodexAppServerException) {
|
||||
assertThat(e.message).contains(invalidExecutable)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
client.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listThreadsReportsDefaultExecutableStartFailuresWithoutCliMissingError(): Unit = runBlocking(Dispatchers.IO) {
|
||||
val workingDir = tempDir.resolve("project-invalid-env")
|
||||
Files.createDirectories(workingDir)
|
||||
val configPath = workingDir.resolve("codex-config.json")
|
||||
writeConfig(path = configPath, threads = emptyList())
|
||||
|
||||
val client = CodexAppServerClient(
|
||||
coroutineScope = this,
|
||||
executablePathProvider = { null },
|
||||
environmentOverrides = mapOf("INVALID=KEY" to "value"),
|
||||
workingDirectory = workingDir,
|
||||
)
|
||||
try {
|
||||
try {
|
||||
client.listThreads(archived = false)
|
||||
fail("Expected CodexAppServerException")
|
||||
}
|
||||
catch (e: CodexAppServerException) {
|
||||
assertThat(e).isNotInstanceOf(CodexCliNotFoundException::class.java)
|
||||
assertThat(e.message).contains("Failed to start Codex app-server from codex")
|
||||
}
|
||||
}
|
||||
finally {
|
||||
client.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,10 @@ internal fun createMockClient(
|
||||
)
|
||||
}
|
||||
|
||||
internal fun createMockCodexShim(tempDir: Path, configPath: Path): Path {
|
||||
return createCodexShim(tempDir, configPath)
|
||||
}
|
||||
|
||||
private fun createCodexShim(tempDir: Path, configPath: Path): Path {
|
||||
val javaHome = System.getProperty("java.home")
|
||||
val javaBin = Path.of(javaHome, "bin", if (OS.CURRENT == OS.Windows) "java.exe" else "java")
|
||||
|
||||
Reference in New Issue
Block a user