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
+4
View File
@@ -142,6 +142,10 @@
- Fixed heavily branched conversation trees shifting linear continuations into disconnected columns.
- Fixed plugin installation validation failures for legacy compatibility shims.
- Removed hard-coded references to disabled or absent agents in system and tool prompts.
### Added
- Added resumable session details to fatal crash output, including an `omp --resume <session-id>` command for every persisted live agent session.
### Fixed
- Fixed unobserved promise continuations from browser helpers such as `tab.waitForResponse()` wedging or killing the tab worker when they reject; browser facade promises now retain native promise behavior while observing every `then`, `catch`, and `finally` continuation.
@@ -82,6 +82,7 @@ import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
import {
$env,
APP_NAME,
escapeXmlText,
formatDuration,
getAgentDbPath,
@@ -445,6 +446,7 @@ export class AgentSession {
// Event subscription state
#unsubscribeAgent?: () => void;
#cancelExitRecorder?: () => void;
#cancelFatalRecoveryHint?: () => void;
#exitRecorded = false;
#unsubscribeAppendOnly?: () => void;
#unsubscribeModelRoles?: () => void;
@@ -1376,6 +1378,14 @@ export class AgentSession {
this.#cancelExitRecorder = postmortem.register(`agent-session:${this.sessionManager.getSessionId()}`, reason => {
this.#recordSessionExit(reason);
});
this.#cancelFatalRecoveryHint = postmortem.registerFatalRecoveryHint(() => {
const sessionId = this.sessionManager.getSessionId();
if (!sessionId || !this.sessionManager.getSessionFile()) return undefined;
return {
label: this.#agentId ?? (this.#agentKind === "main" ? "Main" : "Agent"),
command: `${APP_NAME} --resume ${sessionId}`,
};
});
const advisorsHost: SessionAdvisorsHost = {
agent: this.agent,
@@ -3758,6 +3768,8 @@ export class AgentSession {
this.#recordSessionExit(options.reason ?? "dispose");
this.#cancelExitRecorder?.();
this.#cancelExitRecorder = undefined;
this.#cancelFatalRecoveryHint?.();
this.#cancelFatalRecoveryHint = undefined;
try {
await emitSessionShutdownEvent(this.#extensionRunner);
} catch (error) {
+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";