feat(partnerctl): add background run mode and log command with pid/log file management

This commit is contained in:
2026-05-05 23:57:32 +08:00
parent bd4d5164d3
commit 8398c14794
4 changed files with 265 additions and 20 deletions

View File

@@ -15,6 +15,7 @@ import kotlin.system.exitProcess
InitCommand::class,
RunCommand::class,
ShutdownCommand::class,
LogCommand::class,
ChatCommand::class,
ConfigCommand::class,
ModuleCommand::class,

View File

@@ -3,16 +3,22 @@ package work.slhaf.partner.ctl.commands
import picocli.CommandLine
import work.slhaf.partner.ctl.i18n.I18n.text
import work.slhaf.partner.ctl.support.CommandInterrupted
import work.slhaf.partner.ctl.support.inheritCommand
import java.io.InputStream
import java.io.OutputStream
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardOpenOption
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import kotlin.concurrent.thread
@CommandLine.Command(name = "run", description = ["Start Partner agent."])
class RunCommand : Runnable {
@CommandLine.Option(names = ["-d", "--background"], description = ["Run Partner in background."])
var background: Boolean = false
override fun run() {
val home = resolvePartnerHome()
val partnerJar = resolvePartnerJar(home)
@@ -21,13 +27,10 @@ class RunCommand : Runnable {
throw CommandInterrupted(text("control.run.error.jarNotFound", partnerJar))
}
val exitCode = inheritCommand(
command = listOf("java", "-jar", partnerJar.toString()),
environment = mapOf("PARTNER_HOME" to home.toString()),
)
if (exitCode != 0) {
throw CommandInterrupted(text("control.run.error.exited", exitCode), exitCode)
if (background) {
runInBackground(home, partnerJar)
} else {
runInForeground(home, partnerJar)
}
}
}
@@ -50,9 +53,11 @@ class ShutdownCommand : Runnable {
override fun run() {
val home = resolvePartnerHome()
val partnerJar = resolvePartnerJar(home)
val processes = findPartnerProcesses(partnerJar)
val pidFile = resolvePidFile(home)
val processes = findPartnerProcesses(home, partnerJar)
if (processes.isEmpty()) {
cleanupStalePidFile(pidFile)
println(text("control.shutdown.info.notRunning", partnerJar))
return
}
@@ -66,6 +71,7 @@ class ShutdownCommand : Runnable {
val stopped = waitForExit(process, timeoutSeconds)
if (stopped) {
println(text("control.shutdown.success.stopped", pid))
deletePidFileIfMatches(pidFile, pid)
return@forEach
}
@@ -74,6 +80,7 @@ class ShutdownCommand : Runnable {
process.destroyForcibly()
if (waitForExit(process, timeoutSeconds)) {
println(text("control.shutdown.success.stopped", pid))
deletePidFileIfMatches(pidFile, pid)
} else {
failed = true
println(text("control.shutdown.error.notStopped", pid))
@@ -90,6 +97,108 @@ class ShutdownCommand : Runnable {
}
}
@CommandLine.Command(name = "log", description = ["Show Partner logs."])
class LogCommand : Runnable {
@CommandLine.Option(names = ["--tail"], description = ["Number of log lines to show before exiting or following."])
var tailLines: Int = 200
@CommandLine.Option(names = ["-f", "--follow"], description = ["Follow appended log output."])
var follow: Boolean = false
override fun run() {
val home = resolvePartnerHome()
val logFile = resolveLogFile(home)
if (!Files.exists(logFile)) {
if (!follow) {
println(text("control.log.info.notFound", logFile))
return
}
println(text("control.log.info.waiting", logFile))
waitForLogFile(logFile)
}
printLastLines(logFile, tailLines)
if (follow) {
followLog(logFile)
}
}
}
private fun runInForeground(home: Path, partnerJar: Path) {
val logFile = resolveLogFile(home)
Files.createDirectories(logFile.parent)
val process = createPartnerProcessBuilder(home, partnerJar)
.redirectInput(ProcessBuilder.Redirect.INHERIT)
.redirectErrorStream(true)
.start()
val shutdownHook = Thread {
if (process.isAlive) {
process.destroy()
}
}
Runtime.getRuntime().addShutdownHook(shutdownHook)
val logOutput = Files.newOutputStream(
logFile,
StandardOpenOption.CREATE,
StandardOpenOption.APPEND,
)
val copier = thread(name = "partnerctl-run-log-tee") {
logOutput.use { output ->
tee(process.inputStream, output)
}
}
val exitCode = process.waitFor()
copier.join()
runCatching { Runtime.getRuntime().removeShutdownHook(shutdownHook) }
if (exitCode != 0) {
throw CommandInterrupted(text("control.run.error.exited", exitCode), exitCode)
}
}
private fun runInBackground(home: Path, partnerJar: Path) {
val pidFile = resolvePidFile(home)
val logFile = resolveLogFile(home)
val existingProcess = findPartnerProcesses(home, partnerJar).firstOrNull()
if (existingProcess != null) {
throw CommandInterrupted(text("control.run.error.alreadyRunning", existingProcess.pid()))
}
Files.createDirectories(pidFile.parent)
Files.createDirectories(logFile.parent)
val process = createPartnerProcessBuilder(home, partnerJar)
.redirectOutput(ProcessBuilder.Redirect.appendTo(logFile.toFile()))
.redirectErrorStream(true)
.start()
Files.writeString(
pidFile,
process.pid().toString(),
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
)
println(text("control.run.success.backgroundStarted", process.pid()))
println(text("control.run.info.logFile", logFile))
}
private fun createPartnerProcessBuilder(home: Path, partnerJar: Path): ProcessBuilder {
return ProcessBuilder("java", "-jar", partnerJar.toString())
.apply {
environment()["PARTNER_HOME"] = home.toString()
}
}
private fun resolvePartnerHome(): Path {
val home = System.getenv("PARTNER_HOME")
?.trim()
@@ -104,23 +213,73 @@ private fun resolvePartnerJar(home: Path): Path {
return home.resolve("resource").resolve("partner-core.jar").toAbsolutePath().normalize()
}
private fun findPartnerProcesses(partnerJar: Path): List<ProcessHandle> {
val currentPid = ProcessHandle.current().pid()
val jarPath = partnerJar.toString()
private fun resolvePidFile(home: Path): Path {
return home.resolve("state").resolve("run").resolve("partner.pid").toAbsolutePath().normalize()
}
private fun resolveLogFile(home: Path): Path {
return home.resolve("state").resolve("trace").resolve("log").resolve("partner-core.log").toAbsolutePath().normalize()
}
private fun findPartnerProcesses(home: Path, partnerJar: Path): List<ProcessHandle> {
val pidFile = resolvePidFile(home)
val pidProcess = readPidProcess(pidFile, partnerJar)
if (pidProcess != null) {
return listOf(pidProcess)
}
cleanupStalePidFile(pidFile)
return findPartnerProcessesByCommandLine(partnerJar)
}
private fun readPidProcess(pidFile: Path, partnerJar: Path): ProcessHandle? {
if (!Files.isRegularFile(pidFile)) return null
val pid = Files.readString(pidFile).trim().toLongOrNull() ?: return null
val process = ProcessHandle.of(pid).orElse(null) ?: return null
return if (process.isAlive && isPartnerProcess(process, partnerJar)) {
process
} else {
null
}
}
private fun cleanupStalePidFile(pidFile: Path) {
if (!Files.isRegularFile(pidFile)) return
val pid = Files.readString(pidFile).trim().toLongOrNull()
val process = pid?.let { ProcessHandle.of(it).orElse(null) }
if (process == null || !process.isAlive) {
Files.deleteIfExists(pidFile)
}
}
private fun deletePidFileIfMatches(pidFile: Path, pid: Long) {
if (!Files.isRegularFile(pidFile)) return
val recordedPid = Files.readString(pidFile).trim().toLongOrNull()
if (recordedPid == pid) {
Files.deleteIfExists(pidFile)
}
}
private fun findPartnerProcessesByCommandLine(partnerJar: Path): List<ProcessHandle> {
val currentPid = ProcessHandle.current().pid()
return ProcessHandle.allProcesses()
.filter { it.pid() != currentPid }
.filter { it.isAlive }
.filter { process ->
val info = process.info()
val arguments = info.arguments().orElse(emptyArray()) ?: emptyArray()
val commandLine = info.commandLine().orElse("") ?: ""
arguments.any { it == jarPath } || commandLine.contains(jarPath)
}
.filter { isPartnerProcess(it, partnerJar) }
.toList()
}
private fun isPartnerProcess(process: ProcessHandle, partnerJar: Path): Boolean {
val jarPath = partnerJar.toString()
val info = process.info()
val arguments = info.arguments().orElse(emptyArray()) ?: emptyArray()
val commandLine = info.commandLine().orElse("") ?: ""
return arguments.any { it == jarPath } || commandLine.contains(jarPath)
}
private fun waitForExit(process: ProcessHandle, timeoutSeconds: Long): Boolean {
return try {
process.onExit().get(timeoutSeconds, TimeUnit.SECONDS)
@@ -129,3 +288,78 @@ private fun waitForExit(process: ProcessHandle, timeoutSeconds: Long): Boolean {
false
}
}
private fun tee(input: InputStream, logOutput: OutputStream) {
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read < 0) break
System.out.write(buffer, 0, read)
System.out.flush()
logOutput.write(buffer, 0, read)
logOutput.flush()
}
}
private fun printLastLines(logFile: Path, lineCount: Int) {
if (lineCount <= 0) return
val lines = ArrayDeque<String>()
Files.newBufferedReader(logFile).use { reader ->
while (true) {
val line = reader.readLine() ?: break
if (lines.size == lineCount) {
lines.removeFirst()
}
lines.addLast(line)
}
}
lines.forEach(::println)
}
private fun waitForLogFile(logFile: Path) {
while (!Files.exists(logFile)) {
Thread.sleep(LOG_FOLLOW_INTERVAL_MILLIS)
}
}
private fun followLog(logFile: Path) {
var position = Files.size(logFile)
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
if (!Files.exists(logFile)) {
Thread.sleep(LOG_FOLLOW_INTERVAL_MILLIS)
continue
}
val size = Files.size(logFile)
if (size < position) {
position = 0
}
if (size > position) {
Files.newInputStream(logFile).use { input ->
var skipped = input.skip(position)
while (skipped < position) {
val current = input.skip(position - skipped)
if (current <= 0) break
skipped += current
}
while (true) {
val read = input.read(buffer)
if (read < 0) break
System.out.write(buffer, 0, read)
System.out.flush()
position += read
}
}
}
Thread.sleep(LOG_FOLLOW_INTERVAL_MILLIS)
}
}
private const val LOG_FOLLOW_INTERVAL_MILLIS = 500L

View File

@@ -92,6 +92,11 @@ sourceBuild.tool.mvn.reason=Required to build Partner from source. Command faile
control.run.error.jarNotFound=Partner runtime jar does not exist: {0}. Run partnerctl init first to initialize Partner.
control.run.error.exited=Partner exited with code {0}
control.run.error.alreadyRunning=Partner is already running. pid={0}
control.run.success.backgroundStarted=Partner started in background. pid={0}
control.run.info.logFile=Log file: {0}
control.log.info.notFound=Partner log file does not exist: {0}
control.log.info.waiting=Waiting for Partner log file: {0}
control.shutdown.info.notRunning=No running Partner process found for {0}
control.shutdown.info.stopping=Stopping Partner process pid={0}
control.shutdown.success.stopped=Partner process pid={0} stopped

View File

@@ -92,6 +92,11 @@ sourceBuild.tool.mvn.reason=从源码构建 Partner 需要 mvn。命令失败
control.run.error.jarNotFound=Partner runtime jar 不存在:{0}。请先执行 partnerctl init 初始化 Partner。
control.run.error.exited=Partner 退出,退出码:{0}
control.run.error.alreadyRunning=Partner 已经在运行。pid={0}
control.run.success.backgroundStarted=Partner 已在后台启动。pid={0}
control.run.info.logFile=日志文件:{0}
control.log.info.notFound=Partner 日志文件不存在:{0}
control.log.info.waiting=正在等待 Partner 日志文件:{0}
control.shutdown.info.notRunning=没有找到对应 {0} 的运行中 Partner 进程
control.shutdown.info.stopping=正在停止 Partner 进程 pid={0}
control.shutdown.success.stopped=Partner 进程 pid={0} 已停止