fix: ensure CLI exits after command completion (#12906)

* fix: ensure CLI exits after command completion

The CLI process would hang indefinitely after commands like
`openclaw gateway restart` completed successfully.  Two root causes:

1. `runCli()` returned without calling `process.exit()` after
   `program.parseAsync()` resolved, and Commander.js does not
   force-exit the process.

2. `daemon-cli/register.ts` eagerly called `createDefaultDeps()`
   which imported all messaging-provider modules, creating persistent
   event-loop handles that prevented natural Node exit.

Changes:
- Add `flushAndExit()` helper that drains stdout/stderr before calling
  `process.exit()`, preventing truncated piped output in CI/scripts.
- Call `flushAndExit()` after both `tryRouteCli()` and
  `program.parseAsync()` resolve.
- Remove unnecessary `void createDefaultDeps()` from daemon-cli
  registration — daemon lifecycle commands never use messaging deps.
- Make `serveAcpGateway()` return a promise that resolves on
  intentional shutdown (SIGINT/SIGTERM), so `openclaw acp` blocks
  `parseAsync` for the bridge lifetime and exits cleanly on signal.
- Handle the returned promise in the standalone main-module entry
  point to avoid unhandled rejections.

Fixes #12904

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: refactor CLI lifecycle and lazy outbound deps (#12906) (thanks @DrCrinkle)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Taylor Asplund
2026-02-13 15:34:33 -08:00
committed by GitHub
parent 2378d770d1
commit 874ff7089c
7 changed files with 208 additions and 21 deletions
+32 -2
View File
@@ -11,7 +11,7 @@ import { isMainModule } from "../infra/is-main.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { AcpGatewayAgent } from "./translator.js";
export function serveAcpGateway(opts: AcpServerOptions = {}): void {
export function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
const cfg = loadConfig();
const connection = buildGatewayConnectionDetails({
config: cfg,
@@ -34,6 +34,12 @@ export function serveAcpGateway(opts: AcpServerOptions = {}): void {
auth.password;
let agent: AcpGatewayAgent | null = null;
let onClosed!: () => void;
const closed = new Promise<void>((resolve) => {
onClosed = resolve;
});
let stopped = false;
const gateway = new GatewayClient({
url: connection.url,
token: token || undefined,
@@ -50,9 +56,29 @@ export function serveAcpGateway(opts: AcpServerOptions = {}): void {
},
onClose: (code, reason) => {
agent?.handleGatewayDisconnect(`${code}: ${reason}`);
// Resolve only on intentional shutdown (gateway.stop() sets closed
// which skips scheduleReconnect, then fires onClose). Transient
// disconnects are followed by automatic reconnect attempts.
if (stopped) {
onClosed();
}
},
});
const shutdown = () => {
if (stopped) {
return;
}
stopped = true;
gateway.stop();
// If no WebSocket is active (e.g. between reconnect attempts),
// gateway.stop() won't trigger onClose, so resolve directly.
onClosed();
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
const input = Writable.toWeb(process.stdout);
const output = Readable.toWeb(process.stdin) as unknown as ReadableStream<Uint8Array>;
const stream = ndJsonStream(input, output);
@@ -64,6 +90,7 @@ export function serveAcpGateway(opts: AcpServerOptions = {}): void {
}, stream);
gateway.start();
return closed;
}
function parseArgs(args: string[]): AcpServerOptions {
@@ -140,5 +167,8 @@ Options:
if (isMainModule({ currentFile: fileURLToPath(import.meta.url) })) {
const opts = parseArgs(process.argv.slice(2));
serveAcpGateway(opts);
serveAcpGateway(opts).catch((err) => {
console.error(String(err));
process.exit(1);
});
}