mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge pull request #11478 from Kilo-Org/talented-yttrium
feat(cli): improve daemon stop workflow
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
---
|
||||
|
||||
Support stopping the daemon with `kilo console stop` and keeping console or daemon commands attached with `--foreground`
|
||||
+1
-1
@@ -25,7 +25,7 @@
|
||||
| `kilo session` | manage sessions |
|
||||
| `kilo remote` | enable remote connection for real-time session relay |
|
||||
| `kilo daemon` | manage the local kilo daemon |
|
||||
| `kilo console` | open the local Kilo Console |
|
||||
| `kilo console` | open or stop the local Kilo Console |
|
||||
| `kilo db` | database tools |
|
||||
| `kilo config` | configuration tools |
|
||||
| `kilo plugin <module>` | install plugin and update config |
|
||||
|
||||
+52
-26
@@ -891,14 +891,22 @@ Options:
|
||||
manage the local kilo daemon
|
||||
|
||||
Commands:
|
||||
kilo daemon start the local kilo daemon [default]
|
||||
kilo daemon start start the local kilo daemon
|
||||
kilo daemon status show local kilo daemon status
|
||||
kilo daemon stop stop the local kilo daemon
|
||||
kilo daemon restart restart the local kilo daemon
|
||||
|
||||
Options:
|
||||
--help Show help [boolean]
|
||||
--version Show version number [boolean]
|
||||
--help Show help [boolean]
|
||||
--version Show version number [boolean]
|
||||
--port port to listen on [number] [default: 0]
|
||||
--hostname hostname to listen on [string] [default: "127.0.0.1"]
|
||||
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
|
||||
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
|
||||
--cors additional domains to allow for CORS [array] [default: []]
|
||||
--json print daemon details as JSON [boolean]
|
||||
-f, --foreground keep the command active until interrupted [boolean]
|
||||
```
|
||||
|
||||
### kilo daemon start
|
||||
@@ -907,14 +915,15 @@ Options:
|
||||
start the local kilo daemon
|
||||
|
||||
Options:
|
||||
--help Show help [boolean]
|
||||
--version Show version number [boolean]
|
||||
--port port to listen on [number] [default: 0]
|
||||
--hostname hostname to listen on [string] [default: "127.0.0.1"]
|
||||
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
|
||||
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
|
||||
--cors additional domains to allow for CORS [array] [default: []]
|
||||
--json print daemon details as JSON [boolean]
|
||||
--help Show help [boolean]
|
||||
--version Show version number [boolean]
|
||||
--port port to listen on [number] [default: 0]
|
||||
--hostname hostname to listen on [string] [default: "127.0.0.1"]
|
||||
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
|
||||
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
|
||||
--cors additional domains to allow for CORS [array] [default: []]
|
||||
--json print daemon details as JSON [boolean]
|
||||
-f, --foreground keep the command active until interrupted [boolean]
|
||||
```
|
||||
|
||||
### kilo daemon status
|
||||
@@ -945,29 +954,46 @@ Options:
|
||||
restart the local kilo daemon
|
||||
|
||||
Options:
|
||||
--help Show help [boolean]
|
||||
--version Show version number [boolean]
|
||||
--port port to listen on [number] [default: 0]
|
||||
--hostname hostname to listen on [string] [default: "127.0.0.1"]
|
||||
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
|
||||
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
|
||||
--cors additional domains to allow for CORS [array] [default: []]
|
||||
--json print daemon details as JSON [boolean]
|
||||
--help Show help [boolean]
|
||||
--version Show version number [boolean]
|
||||
--port port to listen on [number] [default: 0]
|
||||
--hostname hostname to listen on [string] [default: "127.0.0.1"]
|
||||
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
|
||||
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
|
||||
--cors additional domains to allow for CORS [array] [default: []]
|
||||
--json print daemon details as JSON [boolean]
|
||||
-f, --foreground keep the command active until interrupted [boolean]
|
||||
```
|
||||
|
||||
## kilo console
|
||||
|
||||
```
|
||||
open the local Kilo Console
|
||||
open or stop the local Kilo Console
|
||||
|
||||
Commands:
|
||||
kilo console open the local Kilo Console [default]
|
||||
kilo console stop stop the local kilo daemon
|
||||
|
||||
Options:
|
||||
--help Show help [boolean]
|
||||
--version Show version number [boolean]
|
||||
--port port to listen on [number] [default: 0]
|
||||
--hostname hostname to listen on [string] [default: "127.0.0.1"]
|
||||
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
|
||||
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
|
||||
--cors additional domains to allow for CORS [array] [default: []]
|
||||
--help Show help [boolean]
|
||||
--version Show version number [boolean]
|
||||
--port port to listen on [number] [default: 0]
|
||||
--hostname hostname to listen on [string] [default: "127.0.0.1"]
|
||||
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
|
||||
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
|
||||
--cors additional domains to allow for CORS [array] [default: []]
|
||||
-f, --foreground keep the command active until interrupted [boolean]
|
||||
```
|
||||
|
||||
### kilo console stop
|
||||
|
||||
```
|
||||
stop the local kilo daemon
|
||||
|
||||
Options:
|
||||
--help Show help [boolean]
|
||||
--version Show version number [boolean]
|
||||
--json print daemon details as JSON [boolean]
|
||||
```
|
||||
|
||||
## kilo db
|
||||
|
||||
@@ -177,7 +177,7 @@ Remote HTTP proxy responses can include sync fence metadata. Router waits for ma
|
||||
|
||||
## Daemon lifecycle
|
||||
|
||||
`kilo daemon start|status|stop|restart` manage detached local `kilo serve` child. `kilo console` calls same start path, so it reuses healthy daemon instead of spawning second process.
|
||||
`kilo daemon start|status|stop|restart` manage a detached local `kilo serve` child, with bare `kilo daemon` equivalent to `kilo daemon start`. `kilo console` calls the same start path, so it reuses a healthy daemon instead of spawning a second process, while `kilo console stop` aliases `kilo daemon stop`.
|
||||
|
||||
| Area | Behavior |
|
||||
|---|---|
|
||||
@@ -185,6 +185,7 @@ Remote HTTP proxy responses can include sync fence metadata. Router waits for ma
|
||||
| Log file | `${Global.Path.log}/daemon.log`, created with mode `0600` |
|
||||
| Port allocation | For `--port 0`, scans `4097..4116` and chooses available port |
|
||||
| Child process | Detached `kilo serve --hostname <host> --port <port>` process |
|
||||
| Foreground mode | `--foreground` / `-f` keeps the invoking command attached; SIGINT, SIGTERM, or SIGHUP stops only the daemon identity it started or reused |
|
||||
| Health | Probes authenticated `/global/health` with 2 second timeout |
|
||||
| Reuse | Reuses daemon only when process is alive, health succeeds, and installed version matches |
|
||||
| Cleanup | Terminates stale process when present, clears stale state, then starts replacement |
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import open from "open"
|
||||
import type { Argv } from "yargs"
|
||||
import { cmd } from "@/cli/cmd/cmd"
|
||||
import { explicitNetworkOptions, withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||
import { serverUrls } from "@/kilocode/cli/server-urls"
|
||||
@@ -6,6 +7,7 @@ import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Daemon } from "@/kilocode/daemon/daemon"
|
||||
import { warnPort } from "@/kilocode/cli/port-warning"
|
||||
import { hasDisplay } from "@/kilocode/cli/cmd/tui/util/display"
|
||||
import { StopCommand } from "@/kilocode/cli/cmd/daemon"
|
||||
|
||||
function browserUrl(state: Daemon.State) {
|
||||
const url = new URL("/console", state.url)
|
||||
@@ -34,31 +36,56 @@ async function launch(url: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export const KiloConsoleCommand = cmd({
|
||||
command: "console",
|
||||
const OpenCommand = cmd({
|
||||
command: "$0",
|
||||
describe: "open the local Kilo Console",
|
||||
builder: (yargs) => withNetworkOptions(yargs),
|
||||
builder: (yargs) =>
|
||||
withNetworkOptions(yargs).option("foreground", {
|
||||
alias: "f",
|
||||
describe: "keep the command active until interrupted",
|
||||
type: "boolean",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const opts = await AppRuntime.runPromise(resolveNetworkOptions(args))
|
||||
warnPort(opts.port)
|
||||
const daemon = await Daemon.ensure(opts, explicitNetworkOptions())
|
||||
if (daemon.restarted) console.warn("Restarted the Kilo daemon to apply the requested network options")
|
||||
const state = daemon.result.state
|
||||
if (!state) throw new Error("Kilo daemon did not provide connection state")
|
||||
const run = async (signal?: AbortSignal) => {
|
||||
const opts = await AppRuntime.runPromise(resolveNetworkOptions(args))
|
||||
warnPort(opts.port)
|
||||
const daemon = await Daemon.ensure(opts, explicitNetworkOptions())
|
||||
const state = daemon.result.state
|
||||
if (!state) throw new Error("Kilo daemon did not provide connection state")
|
||||
if (signal?.aborted) return state
|
||||
if (daemon.restarted) console.warn("Restarted the Kilo daemon to apply the requested network options")
|
||||
|
||||
const urls = state.urls ?? serverUrls(state.hostname, state.port)
|
||||
const consoleLocal = `${urls.local}/console`
|
||||
const consoleNetwork = urls.network ? `${urls.network}/console` : undefined
|
||||
const urls = state.urls ?? serverUrls(state.hostname, state.port)
|
||||
const consoleLocal = `${urls.local}/console`
|
||||
const consoleNetwork = urls.network ? `${urls.network}/console` : undefined
|
||||
|
||||
if (hasDisplay()) {
|
||||
await launch(browserUrl(state)).catch((err) => {
|
||||
console.warn(`Could not open browser automatically: ${err instanceof Error ? err.message : String(err)}`)
|
||||
})
|
||||
} else {
|
||||
console.warn("No display detected; open the Kilo Console URL manually")
|
||||
if (hasDisplay()) {
|
||||
await launch(browserUrl(state)).catch((err) => {
|
||||
console.warn(`Could not open browser automatically: ${err instanceof Error ? err.message : String(err)}`)
|
||||
})
|
||||
} else {
|
||||
console.warn("No display detected; open the Kilo Console URL manually")
|
||||
}
|
||||
console.log("Kilo Console:")
|
||||
console.log(` Local: ${consoleLocal}`)
|
||||
if (consoleNetwork) console.log(` Network: ${consoleNetwork}`)
|
||||
return state
|
||||
}
|
||||
console.log("Kilo Console:")
|
||||
console.log(` Local: ${consoleLocal}`)
|
||||
if (consoleNetwork) console.log(` Network: ${consoleNetwork}`)
|
||||
if (!args.foreground) {
|
||||
await run()
|
||||
return
|
||||
}
|
||||
await Daemon.foreground(async (signal) => {
|
||||
const state = await run(signal)
|
||||
if (!signal.aborted) console.log("Press Ctrl+C to stop the Kilo daemon.")
|
||||
return state
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const KiloConsoleCommand = cmd({
|
||||
command: "console",
|
||||
describe: "open or stop the local Kilo Console",
|
||||
builder: (yargs: Argv) => yargs.command(OpenCommand).command(StopCommand).demandCommand(),
|
||||
handler: async () => {},
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { cmd } from "@/cli/cmd/cmd"
|
||||
import { explicitNetworkOptions, withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||
import { serverUrls } from "@/kilocode/cli/server-urls"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Daemon } from "@/kilocode/daemon/daemon"
|
||||
import { warnPort } from "@/kilocode/cli/port-warning"
|
||||
@@ -13,6 +12,14 @@ function withJson<T>(yargs: Argv<T>) {
|
||||
})
|
||||
}
|
||||
|
||||
function withForeground<T>(yargs: Argv<T>) {
|
||||
return yargs.option("foreground", {
|
||||
alias: "f",
|
||||
describe: "keep the command active until interrupted",
|
||||
type: "boolean",
|
||||
})
|
||||
}
|
||||
|
||||
function safe(input: Daemon.State | undefined) {
|
||||
if (!input) return undefined
|
||||
return {
|
||||
@@ -63,29 +70,51 @@ function print(input: Daemon.Status, json?: boolean) {
|
||||
console.log(`log: ${input.state?.log}`)
|
||||
}
|
||||
|
||||
const StartCommand = cmd({
|
||||
command: "start",
|
||||
describe: "start the local kilo daemon",
|
||||
builder: (yargs) => withJson(withNetworkOptions(yargs)),
|
||||
handler: async (args) => {
|
||||
const opts = await AppRuntime.runPromise(resolveNetworkOptions(args))
|
||||
warnPort(opts.port)
|
||||
const daemon = await Daemon.ensure(opts, explicitNetworkOptions())
|
||||
const result = daemon.result
|
||||
if (args.json) {
|
||||
print(result, true)
|
||||
return
|
||||
}
|
||||
console.log(
|
||||
result.reused
|
||||
? "kilo daemon already running"
|
||||
: daemon.restarted
|
||||
? "kilo daemon restarted"
|
||||
: "kilo daemon started",
|
||||
)
|
||||
print(result)
|
||||
},
|
||||
})
|
||||
async function hold(enabled: boolean, json: boolean, run: (signal?: AbortSignal) => Promise<Daemon.State>) {
|
||||
if (!enabled) {
|
||||
await run()
|
||||
return
|
||||
}
|
||||
await Daemon.foreground(async (signal) => {
|
||||
const state = await run(signal)
|
||||
if (!signal.aborted && !json) console.log("Press Ctrl+C to stop the Kilo daemon.")
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
function start(command: string) {
|
||||
return cmd({
|
||||
command,
|
||||
describe: "start the local kilo daemon",
|
||||
builder: (yargs) => withForeground(withJson(withNetworkOptions(yargs))),
|
||||
handler: async (args) => {
|
||||
await hold(Boolean(args.foreground), Boolean(args.json), async (signal) => {
|
||||
const opts = await AppRuntime.runPromise(resolveNetworkOptions(args))
|
||||
warnPort(opts.port)
|
||||
const daemon = await Daemon.ensure(opts, explicitNetworkOptions())
|
||||
const result = daemon.result
|
||||
const state = result.state
|
||||
if (!state) throw new Error("Kilo daemon did not provide process state")
|
||||
if (signal?.aborted) return state
|
||||
if (args.json) print(result, true)
|
||||
if (!args.json) {
|
||||
console.log(
|
||||
result.reused
|
||||
? "kilo daemon already running"
|
||||
: daemon.restarted
|
||||
? "kilo daemon restarted"
|
||||
: "kilo daemon started",
|
||||
)
|
||||
print(result)
|
||||
}
|
||||
return state
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const DefaultCommand = start("$0")
|
||||
const StartCommand = start("start")
|
||||
|
||||
const StatusCommand = cmd({
|
||||
command: "status",
|
||||
@@ -96,7 +125,7 @@ const StatusCommand = cmd({
|
||||
},
|
||||
})
|
||||
|
||||
const StopCommand = cmd({
|
||||
export const StopCommand = cmd({
|
||||
command: "stop",
|
||||
describe: "stop the local kilo daemon",
|
||||
builder: (yargs) => withJson(yargs),
|
||||
@@ -113,17 +142,22 @@ const StopCommand = cmd({
|
||||
const RestartCommand = cmd({
|
||||
command: "restart",
|
||||
describe: "restart the local kilo daemon",
|
||||
builder: (yargs) => withJson(withNetworkOptions(yargs)),
|
||||
builder: (yargs) => withForeground(withJson(withNetworkOptions(yargs))),
|
||||
handler: async (args) => {
|
||||
const opts = await AppRuntime.runPromise(resolveNetworkOptions(args))
|
||||
warnPort(opts.port)
|
||||
const result = await Daemon.restart(opts)
|
||||
if (args.json) {
|
||||
print(result, true)
|
||||
return
|
||||
}
|
||||
console.log("kilo daemon restarted")
|
||||
print(result)
|
||||
await hold(Boolean(args.foreground), Boolean(args.json), async (signal) => {
|
||||
const opts = await AppRuntime.runPromise(resolveNetworkOptions(args))
|
||||
warnPort(opts.port)
|
||||
const result = await Daemon.restart(opts)
|
||||
const state = result.state
|
||||
if (!state) throw new Error("Kilo daemon did not provide process state")
|
||||
if (signal?.aborted) return state
|
||||
if (args.json) print(result, true)
|
||||
if (!args.json) {
|
||||
console.log("kilo daemon restarted")
|
||||
print(result)
|
||||
}
|
||||
return state
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -131,6 +165,12 @@ export const DaemonCommand = cmd({
|
||||
command: "daemon",
|
||||
describe: "manage the local kilo daemon",
|
||||
builder: (yargs: Argv) =>
|
||||
yargs.command(StartCommand).command(StatusCommand).command(StopCommand).command(RestartCommand).demandCommand(),
|
||||
yargs
|
||||
.command(DefaultCommand)
|
||||
.command(StartCommand)
|
||||
.command(StatusCommand)
|
||||
.command(StopCommand)
|
||||
.command(RestartCommand)
|
||||
.demandCommand(),
|
||||
handler: async () => {},
|
||||
})
|
||||
|
||||
@@ -83,6 +83,8 @@ export namespace Daemon {
|
||||
stopped: boolean
|
||||
}
|
||||
|
||||
export type Identity = Pick<State, "pid" | "startedAt">
|
||||
|
||||
function root() {
|
||||
return process.env.KILO_TEST_DAEMON_STATE_DIR ?? Global.Path.state
|
||||
}
|
||||
@@ -238,12 +240,12 @@ export namespace Daemon {
|
||||
return await run(input, explicit)
|
||||
}
|
||||
|
||||
export async function stop(): Promise<Stop> {
|
||||
export async function stop(expected?: Identity): Promise<Stop> {
|
||||
return await Flock.withLock(
|
||||
lock,
|
||||
async () => {
|
||||
const current = await status()
|
||||
if (!current.state) return { ...current, stopped: false }
|
||||
if (!current.state || (expected && !same(current.state, expected))) return { ...current, stopped: false }
|
||||
if (alive(current.state.pid)) {
|
||||
await terminate(current.state.pid, false)
|
||||
if (alive(current.state.pid)) await terminate(current.state.pid, true)
|
||||
@@ -255,6 +257,31 @@ export namespace Daemon {
|
||||
)
|
||||
}
|
||||
|
||||
export async function foreground(start: (signal: AbortSignal) => Promise<Identity>) {
|
||||
const ctl = new AbortController()
|
||||
const interrupt = new AbortController()
|
||||
const done = Promise.withResolvers<"signal">()
|
||||
const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const
|
||||
const quit = () => {
|
||||
interrupt.abort()
|
||||
done.resolve("signal")
|
||||
}
|
||||
for (const signal of signals) process.once(signal, quit)
|
||||
|
||||
try {
|
||||
const expected = await start(interrupt.signal)
|
||||
if (interrupt.signal.aborted) {
|
||||
await stop(expected)
|
||||
return
|
||||
}
|
||||
const result = await Promise.race([done.promise, watch(expected, ctl.signal)])
|
||||
if (result === "signal") await stop(expected)
|
||||
} finally {
|
||||
ctl.abort()
|
||||
for (const signal of signals) process.off(signal, quit)
|
||||
}
|
||||
}
|
||||
|
||||
export async function restart(input: Options): Promise<Start> {
|
||||
return (await run(input, [], true)).result
|
||||
}
|
||||
@@ -380,8 +407,36 @@ export namespace Daemon {
|
||||
return { hostname: match[1], port: Number(match[2]) }
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
function same(state: State, expected: Identity) {
|
||||
return state.pid === expected.pid && state.startedAt === expected.startedAt
|
||||
}
|
||||
|
||||
async function watch(expected: Identity, signal: AbortSignal): Promise<"daemon"> {
|
||||
while (!signal.aborted) {
|
||||
const state = await read()
|
||||
if (!state || !same(state, expected) || !alive(expected.pid)) return "daemon"
|
||||
await sleep(250, signal)
|
||||
}
|
||||
return "daemon"
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const done = () => {
|
||||
signal?.removeEventListener("abort", cancel)
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(done, ms)
|
||||
const cancel = () => {
|
||||
clearTimeout(timer)
|
||||
done()
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
signal?.addEventListener("abort", cancel, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
async function terminate(pid: number, force: boolean) {
|
||||
|
||||
@@ -49,6 +49,50 @@ function opts(root: string): Daemon.Options {
|
||||
}
|
||||
}
|
||||
|
||||
function cli(args: string[], env: NodeJS.ProcessEnv = {}) {
|
||||
return Bun.spawn([process.execPath, "--conditions=browser", path.join(process.cwd(), "src/index.ts"), ...args], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
KILO_CONFIG_CONTENT: '{"experimental":{"openTelemetry":false}}',
|
||||
KILO_DISABLE_PROJECT_CONFIG: "1",
|
||||
KILO_DISABLE_AUTOUPDATE: "1",
|
||||
KILO_DISABLE_MODELS_FETCH: "1",
|
||||
KILO_AUTH_CONTENT: "{}",
|
||||
KILO_PURE: "1",
|
||||
},
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
}
|
||||
|
||||
function capture(stream: ReadableStream<Uint8Array>, match: string) {
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const text = (async () => {
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const chunks: string[] = []
|
||||
while (true) {
|
||||
const part = await reader.read()
|
||||
if (part.done) break
|
||||
chunks.push(decoder.decode(part.value, { stream: true }))
|
||||
if (chunks.join("").includes(match)) ready.resolve()
|
||||
}
|
||||
chunks.push(decoder.decode())
|
||||
return chunks.join("")
|
||||
})()
|
||||
return { ready: ready.promise, text }
|
||||
}
|
||||
|
||||
async function deadline<T>(promise: Promise<T>, timeout: number) {
|
||||
const expired = Symbol("expired")
|
||||
const result = await Promise.race([promise, Bun.sleep(timeout).then(() => expired)])
|
||||
if (result === expired) throw new Error(`Timed out after ${timeout}ms`)
|
||||
return result
|
||||
}
|
||||
|
||||
describe("daemon manager", () => {
|
||||
test("reports not running without daemon state", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
@@ -156,6 +200,128 @@ describe("daemon manager", () => {
|
||||
expect(restarted.status).toBe(200)
|
||||
}, 20_000)
|
||||
|
||||
test("does not let a foreground owner stop a replacement daemon", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const input = opts(tmp.path)
|
||||
const first = await Daemon.start(input)
|
||||
const state = first.state
|
||||
if (!state) throw new Error("Daemon did not provide process state")
|
||||
const waiting = Daemon.foreground(async () => state)
|
||||
|
||||
const second = await Daemon.restart(input)
|
||||
await deadline(waiting, 5_000)
|
||||
const stopped = await Daemon.stop(state)
|
||||
const current = await Daemon.status()
|
||||
|
||||
expect(stopped.stopped).toBe(false)
|
||||
expect(current.running).toBe(true)
|
||||
expect(current.state?.pid).toBe(second.state?.pid)
|
||||
expect(current.state?.pid).not.toBe(state.pid)
|
||||
}, 30_000)
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"records foreground interrupts while startup is pending",
|
||||
async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const input = opts(tmp.path)
|
||||
await Daemon.start(input)
|
||||
const ready = path.join(tmp.path, "foreground-ready")
|
||||
const release = path.join(tmp.path, "foreground-release")
|
||||
const source = path.join(process.cwd(), "src/kilocode/daemon/daemon.ts")
|
||||
const script = `
|
||||
import { Daemon } from ${JSON.stringify(source)}
|
||||
await Daemon.foreground(async () => {
|
||||
await Bun.write(${JSON.stringify(ready)}, "ready")
|
||||
while (!(await Bun.file(${JSON.stringify(release)}).exists())) await Bun.sleep(10)
|
||||
const state = await Daemon.read()
|
||||
if (!state) throw new Error("Daemon did not provide process state")
|
||||
return state
|
||||
})
|
||||
`
|
||||
const proc = Bun.spawn([process.execPath, "--conditions=browser", "-e", script], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, ...input.env },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const stdout = new Response(proc.stdout).text()
|
||||
const stderr = new Response(proc.stderr).text()
|
||||
|
||||
try {
|
||||
await deadline(
|
||||
(async () => {
|
||||
while (!(await Bun.file(ready).exists())) await Bun.sleep(10)
|
||||
})(),
|
||||
5_000,
|
||||
)
|
||||
proc.kill("SIGINT")
|
||||
await Bun.write(release, "release")
|
||||
expect(await deadline(proc.exited, 10_000)).toBe(0)
|
||||
expect((await Daemon.status()).running).toBe(false)
|
||||
await Promise.all([stdout, stderr])
|
||||
} finally {
|
||||
if (proc.exitCode === null) proc.kill("SIGKILL")
|
||||
await proc.exited
|
||||
await Daemon.stop()
|
||||
}
|
||||
},
|
||||
25_000,
|
||||
)
|
||||
|
||||
test("supports console stop as a daemon stop alias", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const input = opts(tmp.path)
|
||||
await Daemon.start(input)
|
||||
const proc = cli(["console", "stop"], input.env)
|
||||
const [code, stdout, stderr] = await Promise.all([
|
||||
deadline(proc.exited, 20_000),
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
])
|
||||
|
||||
expect(code).toBe(0)
|
||||
expect(stdout).toContain("kilo daemon stopped")
|
||||
expect(stderr).not.toContain("Could not open browser automatically")
|
||||
expect((await Daemon.status()).running).toBe(false)
|
||||
}, 30_000)
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"stops a foreground daemon on SIGINT",
|
||||
async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const env = dirs(tmp.path)
|
||||
const proc = cli(["daemon", "-f", "--port", "0"], env)
|
||||
const stdout = capture(proc.stdout, "Press Ctrl+C to stop the Kilo daemon.")
|
||||
const stderr = new Response(proc.stderr).text()
|
||||
|
||||
try {
|
||||
await deadline(
|
||||
Promise.race([
|
||||
stdout.ready,
|
||||
stdout.text.then(() => {
|
||||
throw new Error("Foreground daemon exited before becoming ready")
|
||||
}),
|
||||
]),
|
||||
20_000,
|
||||
)
|
||||
const state = await Daemon.status()
|
||||
expect(state.running).toBe(true)
|
||||
expect(proc.exitCode).toBeNull()
|
||||
|
||||
proc.kill("SIGINT")
|
||||
expect(await deadline(proc.exited, 10_000)).toBe(0)
|
||||
expect((await Daemon.status()).running).toBe(false)
|
||||
expect(await stdout.text).toContain("kilo daemon started")
|
||||
await stderr
|
||||
} finally {
|
||||
if (proc.exitCode === null) proc.kill("SIGKILL")
|
||||
await proc.exited
|
||||
await Daemon.stop()
|
||||
}
|
||||
},
|
||||
35_000,
|
||||
)
|
||||
|
||||
test("daemon client does not start a daemon while attaching", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
dirs(tmp.path)
|
||||
|
||||
@@ -24,6 +24,8 @@ import { PluginCommand } from "../../src/cli/cmd/plug"
|
||||
import { DbCommand } from "../../src/cli/cmd/db"
|
||||
import { HelpCommand } from "../../src/kilocode/help-command"
|
||||
import { ProfileCommand } from "../../src/kilocode/cli/cmd/profile"
|
||||
import { DaemonCommand } from "../../src/kilocode/cli/cmd/daemon"
|
||||
import { KiloConsoleCommand } from "../../src/kilocode/cli/cmd/console"
|
||||
|
||||
// Stand-in for TuiThreadCommand — the real one imports @opentui/solid which
|
||||
// doesn't resolve in the test environment. Only command/describe matter here.
|
||||
@@ -72,6 +74,8 @@ const commands = [
|
||||
ConfigCLICommand,
|
||||
PluginCommand,
|
||||
ProfileCommand,
|
||||
DaemonCommand,
|
||||
KiloConsoleCommand,
|
||||
HelpCommand,
|
||||
CompletionStub,
|
||||
] as any[]
|
||||
@@ -120,6 +124,20 @@ describe("kilo help <command>", () => {
|
||||
expect(output).not.toContain("## kilo run")
|
||||
expect(output).not.toContain("## kilo debug")
|
||||
})
|
||||
|
||||
test("documents console stop and foreground mode", async () => {
|
||||
const output = await generateHelp({ command: "console", format: "md", commands })
|
||||
expect(output).toContain("kilo console stop")
|
||||
expect(output).toContain("--foreground")
|
||||
expect(output).toContain("-f")
|
||||
})
|
||||
|
||||
test("documents daemon foreground mode", async () => {
|
||||
const output = await generateHelp({ command: "daemon", format: "md", commands })
|
||||
expect(output).toContain("kilo daemon start")
|
||||
expect(output).toContain("--foreground")
|
||||
expect(output).toContain("-f")
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
|
||||
Reference in New Issue
Block a user