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:
brymko
2026-08-02 13:16:55 +08:00
parent 364e13c49e
commit 56931915f1
5 changed files with 77 additions and 1 deletions
+3
View File
@@ -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
+41 -1
View File
@@ -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";