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, InitCommand::class,
RunCommand::class, RunCommand::class,
ShutdownCommand::class, ShutdownCommand::class,
LogCommand::class,
ChatCommand::class, ChatCommand::class,
ConfigCommand::class, ConfigCommand::class,
ModuleCommand::class, ModuleCommand::class,

View File

@@ -3,16 +3,22 @@ package work.slhaf.partner.ctl.commands
import picocli.CommandLine import picocli.CommandLine
import work.slhaf.partner.ctl.i18n.I18n.text import work.slhaf.partner.ctl.i18n.I18n.text
import work.slhaf.partner.ctl.support.CommandInterrupted 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.Files
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths import java.nio.file.Paths
import java.nio.file.StandardOpenOption
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException import java.util.concurrent.TimeoutException
import kotlin.concurrent.thread
@CommandLine.Command(name = "run", description = ["Start Partner agent."]) @CommandLine.Command(name = "run", description = ["Start Partner agent."])
class RunCommand : Runnable { class RunCommand : Runnable {
@CommandLine.Option(names = ["-d", "--background"], description = ["Run Partner in background."])
var background: Boolean = false
override fun run() { override fun run() {
val home = resolvePartnerHome() val home = resolvePartnerHome()
val partnerJar = resolvePartnerJar(home) val partnerJar = resolvePartnerJar(home)
@@ -21,13 +27,10 @@ class RunCommand : Runnable {
throw CommandInterrupted(text("control.run.error.jarNotFound", partnerJar)) throw CommandInterrupted(text("control.run.error.jarNotFound", partnerJar))
} }
val exitCode = inheritCommand( if (background) {
command = listOf("java", "-jar", partnerJar.toString()), runInBackground(home, partnerJar)
environment = mapOf("PARTNER_HOME" to home.toString()), } else {
) runInForeground(home, partnerJar)
if (exitCode != 0) {
throw CommandInterrupted(text("control.run.error.exited", exitCode), exitCode)
} }
} }
} }
@@ -50,9 +53,11 @@ class ShutdownCommand : Runnable {
override fun run() { override fun run() {
val home = resolvePartnerHome() val home = resolvePartnerHome()
val partnerJar = resolvePartnerJar(home) val partnerJar = resolvePartnerJar(home)
val processes = findPartnerProcesses(partnerJar) val pidFile = resolvePidFile(home)
val processes = findPartnerProcesses(home, partnerJar)
if (processes.isEmpty()) { if (processes.isEmpty()) {
cleanupStalePidFile(pidFile)
println(text("control.shutdown.info.notRunning", partnerJar)) println(text("control.shutdown.info.notRunning", partnerJar))
return return
} }
@@ -66,6 +71,7 @@ class ShutdownCommand : Runnable {
val stopped = waitForExit(process, timeoutSeconds) val stopped = waitForExit(process, timeoutSeconds)
if (stopped) { if (stopped) {
println(text("control.shutdown.success.stopped", pid)) println(text("control.shutdown.success.stopped", pid))
deletePidFileIfMatches(pidFile, pid)
return@forEach return@forEach
} }
@@ -74,6 +80,7 @@ class ShutdownCommand : Runnable {
process.destroyForcibly() process.destroyForcibly()
if (waitForExit(process, timeoutSeconds)) { if (waitForExit(process, timeoutSeconds)) {
println(text("control.shutdown.success.stopped", pid)) println(text("control.shutdown.success.stopped", pid))
deletePidFileIfMatches(pidFile, pid)
} else { } else {
failed = true failed = true
println(text("control.shutdown.error.notStopped", pid)) 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 { private fun resolvePartnerHome(): Path {
val home = System.getenv("PARTNER_HOME") val home = System.getenv("PARTNER_HOME")
?.trim() ?.trim()
@@ -104,23 +213,73 @@ private fun resolvePartnerJar(home: Path): Path {
return home.resolve("resource").resolve("partner-core.jar").toAbsolutePath().normalize() return home.resolve("resource").resolve("partner-core.jar").toAbsolutePath().normalize()
} }
private fun findPartnerProcesses(partnerJar: Path): List<ProcessHandle> { private fun resolvePidFile(home: Path): Path {
val currentPid = ProcessHandle.current().pid() return home.resolve("state").resolve("run").resolve("partner.pid").toAbsolutePath().normalize()
val jarPath = partnerJar.toString() }
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() return ProcessHandle.allProcesses()
.filter { it.pid() != currentPid } .filter { it.pid() != currentPid }
.filter { it.isAlive } .filter { it.isAlive }
.filter { process -> .filter { isPartnerProcess(it, partnerJar) }
val info = process.info()
val arguments = info.arguments().orElse(emptyArray()) ?: emptyArray()
val commandLine = info.commandLine().orElse("") ?: ""
arguments.any { it == jarPath } || commandLine.contains(jarPath)
}
.toList() .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 { private fun waitForExit(process: ProcessHandle, timeoutSeconds: Long): Boolean {
return try { return try {
process.onExit().get(timeoutSeconds, TimeUnit.SECONDS) process.onExit().get(timeoutSeconds, TimeUnit.SECONDS)
@@ -128,4 +287,79 @@ private fun waitForExit(process: ProcessHandle, timeoutSeconds: Long): Boolean {
} catch (_: TimeoutException) { } catch (_: TimeoutException) {
false 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.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.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.notRunning=No running Partner process found for {0}
control.shutdown.info.stopping=Stopping Partner process pid={0} control.shutdown.info.stopping=Stopping Partner process pid={0}
control.shutdown.success.stopped=Partner process pid={0} stopped 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.jarNotFound=Partner runtime jar 不存在:{0}。请先执行 partnerctl init 初始化 Partner。
control.run.error.exited=Partner 退出,退出码:{0} 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.notRunning=没有找到对应 {0} 的运行中 Partner 进程
control.shutdown.info.stopping=正在停止 Partner 进程 pid={0} control.shutdown.info.stopping=正在停止 Partner 进程 pid={0}
control.shutdown.success.stopped=Partner 进程 pid={0} 已停止 control.shutdown.success.stopped=Partner 进程 pid={0} 已停止