pi-mono/packages/coding-agent/src/modes/print-mode.ts
Petr Houška ace560c302
fix(coding-agent): let a started RPC or print shutdown finish; only a repeated signal forces exit (#11752)
Follow-up to #11746 for the case @fcatuhe reported on #11599: a second
shutdown trigger cut off cleanup.

Problem: Paseo stops pi by closing stdin and sending SIGTERM in the same
tick (SIGKILL 2 s later); systemd sends SIGTERM and stdin closes after.
In RPC mode:

- stdin EOF, then SIGTERM: `shutdown()` removed pi's SIGTERM handler
when it started, so the signal killed pi (exit 143) mid-`dispose()`.
- SIGTERM, then stdin EOF: the second `shutdown()` call ran
`process.exit()`.

A pending MCP token refresh was lost: the server had rotated the refresh
token, the next pi sent the old one, and the server revoked the grant.

Rule after this change: once a shutdown has started, a stdin EOF or a
single signal does not interrupt it; a second signal exits at once.
SIGKILL still always works.

- **RPC mode** (`rpc-mode.ts`): signal handlers stay registered until
exit; `shutdown()` runs once and later calls do nothing; the signal
handler counts signals and exits only on a repeated one.
- **Print mode** (`print-mode.ts`): the final `finally` removed the
signal handlers before `disposeRuntime()`, so a signal during the final
disposal killed pi. Handlers are now removed after it,
`disposeRuntime()` returns the shared in-flight disposal (a signal then
waits for it), and a repeated signal exits at once, as before.
- **Interactive mode**: unchanged. It already lets a started shutdown
finish.

Docs (`docs/rpc.md`) and the CHANGELOG are updated.

## Testing
- `test/rpc-prompt-response-semantics.test.ts`: EOF then SIGTERM,
SIGTERM then EOF (both wait for a slow dispose, exit once with 0 and
143), and EOF then two SIGTERMs (exits at once with 143). All three fail
on main.
- `test/print-mode.test.ts`: SIGTERM during the final disposal waits for
it, a second SIGTERM exits with 143, handlers are removed afterwards.
Fails on main.
- Real process, `pi --mode rpc` with a `session_shutdown` handler that
takes 1 s and real signals:
  - EOF then SIGTERM: cleanup done, exit 0 (main: no cleanup, exit 143)
  - SIGTERM then EOF: cleanup done, exit 143
  - EOF then two SIGTERMs: exit 143 at once
- `npm run check` passes; all `test/rpc*` and `test/print-mode` tests
pass.
2026-11-03 22:21:44 +01:00

182 lines
5.6 KiB
TypeScript

/**
* Print mode (single-shot): Send prompts, output result, exit.
*
* Used for:
* - `pi -p "prompt"` - text output
* - `pi --mode json "prompt"` - JSON event stream
*/
import type { AssistantMessage, ImageContent } from "@earendil-works/pi-ai";
import type { AgentSessionRuntime } from "../core/agent-session-runtime.ts";
import { flushRawStdout, waitForRawStdoutBackpressure, writeRawStdout } from "../core/output-guard.ts";
import { killTrackedDetachedChildren } from "../utils/shell.ts";
import { toJsonEvent } from "./json-event.ts";
/**
* Options for print mode.
*/
export interface PrintModeOptions {
/** Output mode: "text" for final response only, "json" for all events */
mode: "text" | "json";
/** Array of additional prompts to send after initialMessage */
messages?: string[];
/** First message to send (may contain @file content) */
initialMessage?: string;
/** Images to attach to the initial message */
initialImages?: ImageContent[];
}
/**
* Run in print (single-shot) mode.
* Sends prompts to the agent and outputs the result.
*/
export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: PrintModeOptions): Promise<number> {
const { mode, messages = [], initialMessage, initialImages } = options;
let exitCode = 0;
let session = runtimeHost.session;
let unsubscribe: (() => void) | undefined;
let unsubscribeBackpressure: (() => void) | undefined;
let disposing: Promise<void> | undefined;
// SIGTERM/SIGHUP received so far. Only a repeated signal forces exit during disposal.
let signalCount = 0;
const signalCleanupHandlers: Array<() => void> = [];
// Callers share one disposal, so a signal during the final disposal waits for it to finish.
const disposeRuntime = (): Promise<void> => {
disposing ??= (async () => {
unsubscribe?.();
unsubscribeBackpressure?.();
await runtimeHost.dispose();
})();
return disposing;
};
const registerSignalHandlers = (): void => {
const signals: NodeJS.Signals[] = ["SIGTERM"];
if (process.platform !== "win32") {
signals.push("SIGHUP");
}
for (const signal of signals) {
const exitCode = signal === "SIGHUP" ? 129 : 143;
const handler = () => {
signalCount++;
killTrackedDetachedChildren();
if (signalCount > 1) process.exit(exitCode);
void disposeRuntime().finally(() => {
process.exit(exitCode);
});
};
process.on(signal, handler);
signalCleanupHandlers.push(() => process.off(signal, handler));
}
};
registerSignalHandlers();
runtimeHost.setRebindSession(async () => {
await rebindSession();
});
const rebindSession = async (): Promise<void> => {
session = runtimeHost.session;
await session.bindExtensions({
mode: mode === "json" ? "json" : "print",
commandContextActions: {
waitForIdle: () => session.waitForIdle(),
newSession: async (newSessionOptions) => runtimeHost.newSession(newSessionOptions),
fork: async (entryId, forkOptions) => {
const result = await runtimeHost.fork(entryId, forkOptions);
return { cancelled: result.cancelled };
},
navigateTree: async (targetId, navigateOptions) => {
const result = await session.navigateTree(targetId, {
summarize: navigateOptions?.summarize,
customInstructions: navigateOptions?.customInstructions,
replaceInstructions: navigateOptions?.replaceInstructions,
label: navigateOptions?.label,
});
return { cancelled: result.cancelled };
},
switchSession: async (sessionPath, switchOptions) => {
return runtimeHost.switchSession(sessionPath, switchOptions);
},
reload: async () => {
await session.reload();
},
},
onError: (err) => {
console.error(`Extension error (${err.extensionPath}): ${err.error}`);
},
});
unsubscribe?.();
unsubscribeBackpressure?.();
unsubscribe = session.subscribe((event) => {
if (mode === "json") {
writeRawStdout(`${JSON.stringify(toJsonEvent(event))}\n`);
}
});
unsubscribeBackpressure =
mode === "json"
? session.agent.subscribe(async () => {
await waitForRawStdoutBackpressure();
})
: undefined;
};
try {
if (mode === "json") {
const header = session.sessionManager.getHeader();
if (header) {
writeRawStdout(`${JSON.stringify(header)}\n`);
}
}
await rebindSession();
if (initialMessage) {
await session.prompt(initialMessage, { images: initialImages });
}
for (const message of messages) {
await session.prompt(message);
}
if (mode === "text") {
const state = session.state;
const lastMessage = state.messages[state.messages.length - 1];
if (lastMessage?.role === "assistant") {
const assistantMsg = lastMessage as AssistantMessage;
if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {
console.error(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`);
exitCode = 1;
} else {
for (const content of assistantMsg.content) {
if (content.type === "text") {
writeRawStdout(`${content.text}\n`);
}
}
if (assistantMsg.stopReason === "length") {
console.error("Response was truncated: the model reached its output token limit.");
exitCode = 1;
}
}
}
}
return exitCode;
} catch (error: unknown) {
console.error(error instanceof Error ? error.message : String(error));
return 1;
} finally {
// Keep the signal handlers until disposal is done: without one, a signal would kill the
// process mid-dispose (for example during a pending MCP token refresh).
await disposeRuntime();
for (const cleanup of signalCleanupHandlers) {
cleanup();
}
await flushRawStdout();
}
}