feat(coding-agent): added fatal session recovery hints
Registered live session resume commands with postmortem handling so a fatal rejection or exception identifies every recoverable agent before cleanup. Escaped terminal control characters in recovery output.
This commit is contained in:
@@ -28,6 +28,9 @@
|
||||
### Changed
|
||||
|
||||
- Updated the lightweight CLI runner to support static command metadata, allowing root help to render without importing full command implementations.
|
||||
### Added
|
||||
|
||||
- Added postmortem fatal recovery hint providers so applications can print actionable recovery commands before cleanup starts.
|
||||
|
||||
## [17.2.4] - 2026-08-01
|
||||
|
||||
|
||||
@@ -67,6 +67,14 @@ function exitProcess(code: number): never {
|
||||
let cleanupPromise: Promise<void> | undefined;
|
||||
let stdioDisconnectRegistrations = 0;
|
||||
|
||||
export interface FatalRecoveryHint {
|
||||
label: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
type FatalRecoveryHintProvider = () => FatalRecoveryHint | undefined;
|
||||
const fatalRecoveryHintProviders = new Set<FatalRecoveryHintProvider>();
|
||||
|
||||
/**
|
||||
* Internal: runs all registered cleanup callbacks for the given reason.
|
||||
* Ensures each callback is invoked at most once. Handles errors and prevents reentrancy.
|
||||
@@ -196,6 +204,38 @@ export function interceptUnhandledRejections(interceptor: (reason: unknown) => b
|
||||
return () => rejectionInterceptors.delete(interceptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a synchronous recovery command to print when the process exits
|
||||
* through an uncaught exception or unhandled rejection.
|
||||
*/
|
||||
export function registerFatalRecoveryHint(provider: FatalRecoveryHintProvider): () => void {
|
||||
fatalRecoveryHintProviders.add(provider);
|
||||
return () => fatalRecoveryHintProviders.delete(provider);
|
||||
}
|
||||
|
||||
function escapeFatalHintText(value: string): string {
|
||||
return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, char => {
|
||||
const code = char.codePointAt(0) ?? 0;
|
||||
return `\\u${code.toString(16).padStart(4, "0")}`;
|
||||
});
|
||||
}
|
||||
|
||||
function formatFatalRecoveryHints(): string {
|
||||
const lines: string[] = [];
|
||||
const seenCommands = new Set<string>();
|
||||
for (const provider of fatalRecoveryHintProviders) {
|
||||
try {
|
||||
const hint = provider();
|
||||
if (!hint?.command || seenCommands.has(hint.command)) continue;
|
||||
seenCommands.add(hint.command);
|
||||
lines.push(` ${escapeFatalHintText(hint.label)}: ${escapeFatalHintText(hint.command)}`);
|
||||
} catch (err) {
|
||||
logger.warn("Fatal recovery hint provider failed", { err });
|
||||
}
|
||||
}
|
||||
return lines.length > 0 ? `\n[Recovery]\n${lines.join("\n")}\n` : "";
|
||||
}
|
||||
|
||||
function formatFatalError(label: string, err: Error): string {
|
||||
const name = err.name || "Error";
|
||||
const message = err.message || "(no message)";
|
||||
@@ -212,7 +252,7 @@ async function exitAfterFatal(label: string, logMessage: string, err: Error, rea
|
||||
// A revoked terminal can make stream writes raise another fatal error. Use
|
||||
// the descriptor directly so failure stays synchronous and contained.
|
||||
try {
|
||||
fs.writeSync(2, formatFatalError(label, err));
|
||||
fs.writeSync(2, `${formatFatalError(label, err)}${formatFatalRecoveryHints()}`);
|
||||
} catch {}
|
||||
logger.error(logMessage, { err });
|
||||
await runCleanup(reason);
|
||||
|
||||
@@ -123,6 +123,23 @@ describe("postmortem expected cleanup errors", () => {
|
||||
expect(result.stderr).toContain("[Unhandled Rejection] Error: unexpected cleanup rejection");
|
||||
});
|
||||
|
||||
it("prints registered recovery commands before fatal cleanup", async () => {
|
||||
const result = await runPostmortemProbe(`
|
||||
import { postmortem } from "${postmortemModuleUrl}";
|
||||
|
||||
postmortem.registerFatalRecoveryHint(() => ({
|
||||
label: "Main",
|
||||
command: "omp --resume 019cafe0-dead-beef",
|
||||
}));
|
||||
Promise.reject(new Error("session crashed"));
|
||||
await Promise.resolve();
|
||||
`);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain("[Unhandled Rejection] Error: session crashed");
|
||||
expect(result.stderr).toContain("[Recovery]\n Main: omp --resume 019cafe0-dead-beef");
|
||||
});
|
||||
|
||||
it("exits after an uncaught exception when terminal stderr is revoked", async () => {
|
||||
const result = await runPostmortemProbe(`
|
||||
import { spyOn } from "bun:test";
|
||||
|
||||
Reference in New Issue
Block a user