Skip to content

Commit ebe21a0

Browse files
committed
Log durability failures instead of swallowing catches
CL-5351 slice A: surface saveState/post-run, settings write-back, resume hydrate, and crash-finalize failures without clobbering files.
1 parent 2921e45 commit ebe21a0

4 files changed

Lines changed: 162 additions & 18 deletions

File tree

src/exec/runner.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ import { getToolApprovalBudget } from "../tui/tool-execution-watchdog.js";
9595

9696
const logger = getLogger([LOG_NAMESPACE_ROOT, "exec"]);
9797

98+
/** Normalize unknown catch values for structured warn/error logs. */
99+
export function formatCaughtError(err: unknown): string {
100+
return err instanceof Error ? err.message : String(err);
101+
}
102+
98103
/** Content-less inbound used after compact so the reactor re-enters (matches TUI). */
99104
function buildCompactionContinuationMessage(): InboundMessage {
100105
return {
@@ -194,7 +199,15 @@ export async function runExec(config: Config): Promise<ExecResult> {
194199
mcpServers: connectedMcp,
195200
...(status !== "running" ? { finishedAt: Date.now() } : {}),
196201
...(extra?.error !== undefined ? { error: extra.error } : {}),
197-
}).catch(() => undefined);
202+
}).catch((err: unknown) => {
203+
// Persistence failure must not fail the run, but dropping it silently
204+
// hides disk/permission problems that leave run.json stale.
205+
logger.warn("saveState failed for session {sessionId} status={status}: {error}", {
206+
sessionId,
207+
status,
208+
error: formatCaughtError(err),
209+
});
210+
});
198211
};
199212

200213

@@ -630,7 +643,13 @@ export async function runExec(config: Config): Promise<ExecResult> {
630643
toolCallCount: runSink.getToolCallCount(),
631644
...(runError !== undefined ? { error: runError } : {}),
632645
});
633-
await hookManager.dispatchPostRun(runSummary).catch(() => undefined);
646+
await hookManager.dispatchPostRun(runSummary).catch((err: unknown) => {
647+
// Post-run hooks are best-effort; keep the exec exit path intact but
648+
// surface the failure so operators can see hook/script problems.
649+
const message = formatCaughtError(err);
650+
logger.warn("dispatchPostRun failed: {error}", { error: message });
651+
stderr.write(`Warning: post-run hook failed: ${message}\n`);
652+
});
634653

635654
if (!sendCompleted || runError !== undefined || summaryStatus === "failed") {
636655
const message =

src/tui/runner.tsx

Lines changed: 100 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,9 @@ import {
134134
} from "../session/runtime-assembly.js";
135135
import { createAttachmentRehydrateTransform } from "../session/attachment-store.js";
136136
import { createModelSummarizer } from "../session/summarizer.js";
137-
import { ID_PREFIX, LOG_NAMESPACE_ROOT } from "../branding.js";
137+
import { COMMAND_NAME, ID_PREFIX, LOG_NAMESPACE_ROOT } from "../branding.js";
138+
139+
const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]);
138140

139141
export function createTUIEventEmitter(): EventEmitter {
140142
return new EventEmitter();
@@ -156,6 +158,30 @@ export function resolveExitCode(args: ResolveExitCodeArgs): number {
156158
return 0;
157159
}
158160

161+
/** One-line transcript block when resume history fails to load. */
162+
export function resumeTranscriptLoadErrorBlock(err: unknown): {
163+
type: "error";
164+
message: string;
165+
} {
166+
const message = err instanceof Error ? err.message : String(err);
167+
return { type: "error", message: `Could not load prior session transcript: ${message}` };
168+
}
169+
170+
/**
171+
* Resolve the base for a local-settings read-modify-write.
172+
* Absent file → empty object; unreadable/invalid → null (caller must skip write).
173+
*/
174+
export async function loadLocalSettingsWriteBase(
175+
path: string,
176+
load: (path: string) => Promise<LocalSettings | null> = loadLocalSettings,
177+
): Promise<LocalSettings | null> {
178+
try {
179+
return (await load(path)) ?? {};
180+
} catch {
181+
return null;
182+
}
183+
}
184+
159185
function buildCompactionContinuationMessage(): InboundMessage {
160186
return {
161187
ref: { uid: 0, mailbox: "system" },
@@ -270,7 +296,13 @@ export async function runTUI(initialConfig: Config): Promise<number> {
270296
const finalizeOnCrash = async (err: unknown): Promise<void> => {
271297
if (finalized) return;
272298
finalized = true;
273-
await flushPartialOnCrash().catch(() => undefined);
299+
await flushPartialOnCrash().catch((flushErr: unknown) => {
300+
// Best-effort only — still attempt saveState below. Log so a flush
301+
// failure is not invisible when diagnosing a crash exit.
302+
const flushMessage = flushErr instanceof Error ? flushErr.message : String(flushErr);
303+
tuiLogger.warn("crash finalize: partial flush failed: {error}", { error: flushMessage });
304+
process.stderr.write(`${COMMAND_NAME}: crash finalize partial flush failed: ${flushMessage}\n`);
305+
});
274306
const message = err instanceof Error ? err.message : String(err);
275307
await saveState(config.cwd, sessionId, {
276308
status: "failed",
@@ -281,7 +313,16 @@ export async function runTUI(initialConfig: Config): Promise<number> {
281313
error: message,
282314
model: `${config.providerName}:${config.model}`,
283315
mcpServers: [],
284-
}).catch(() => undefined);
316+
}).catch((saveErr: unknown) => {
317+
const saveMessage = saveErr instanceof Error ? saveErr.message : String(saveErr);
318+
tuiLogger.warn(
319+
"crash finalize: saveState failed for session {sessionId}: {error}",
320+
{ sessionId, error: saveMessage },
321+
);
322+
process.stderr.write(
323+
`${COMMAND_NAME}: crash finalize saveState failed for ${sessionId}: ${saveMessage}\n`,
324+
);
325+
});
285326
};
286327

287328
try {
@@ -411,8 +452,16 @@ export async function runTUI(initialConfig: Config): Promise<number> {
411452
let liveWebOverride: string | undefined = config.settings?.web;
412453
const livePluginPaths: string[] = [...(config.settings?.pluginPaths ?? [])];
413454
const persistPluginSettings = async (): Promise<void> => {
414-
const current = await loadSettings(config.globalSettingsPath).catch(() => null);
415-
const base: Settings = current ?? { providers: {} };
455+
// Absent file → fresh base; unreadable/invalid → skip write so we never
456+
// clobber a corrupt settings file by rewriting from a minimal shell.
457+
const base = await loadGlobalSettingsWriteBase(config.globalSettingsPath);
458+
if (base === null) {
459+
tuiLogger.warn(
460+
"Skipping plugin settings write: unreadable global settings at {path}",
461+
{ path: config.globalSettingsPath },
462+
);
463+
return;
464+
}
416465
const next: Settings = { ...base, plugins: livePluginConfig };
417466
if (livePluginPaths.length > 0) next.pluginPaths = livePluginPaths;
418467
else delete next.pluginPaths;
@@ -637,7 +686,15 @@ export async function runTUI(initialConfig: Config): Promise<number> {
637686
const picked = await promptSessionModeIfUnset(config.globalSettingsPath);
638687
liveSessionMode = picked ?? "orchestrator";
639688
if (picked !== undefined) {
640-
const refreshed = await loadSettings(config.globalSettingsPath).catch(() => null);
689+
const refreshed = await loadSettings(config.globalSettingsPath).catch((err: unknown) => {
690+
// loadSettings already maps ENOENT → null; a throw is a real I/O or
691+
// schema failure. Keep the in-memory config rather than pretending
692+
// settings are empty.
693+
tuiLogger.warn("Failed to reload settings after session mode pick: {error}", {
694+
error: err instanceof Error ? err.message : String(err),
695+
});
696+
return null;
697+
});
641698
if (refreshed !== null) config = { ...config, settings: refreshed };
642699
}
643700
}
@@ -1405,8 +1462,14 @@ export async function runTUI(initialConfig: Config): Promise<number> {
14051462
{...(config.settings !== undefined ? { initialSettings: config.settings } : {})}
14061463
onChangeCompactionMode={async (mode) => {
14071464
liveCompactionMode = mode;
1408-
const current = await loadSettings(config.globalSettingsPath).catch(() => null);
1409-
const base: Settings = current ?? { providers: {} };
1465+
const base = await loadGlobalSettingsWriteBase(config.globalSettingsPath);
1466+
if (base === null) {
1467+
tuiLogger.warn(
1468+
"Skipping compaction mode write: unreadable global settings at {path}",
1469+
{ path: config.globalSettingsPath },
1470+
);
1471+
return;
1472+
}
14101473
await saveGlobalSettings(config.globalSettingsPath, { ...base, compactionMode: mode });
14111474
}}
14121475
onChangeMaxConcurrentSubAgents={async (limit) => {
@@ -1438,12 +1501,25 @@ export async function runTUI(initialConfig: Config): Promise<number> {
14381501
onChangeSessionMode={async (mode, scope) => {
14391502
if (scope === "local") {
14401503
const path = localSettingsPath(config.cwd);
1441-
const existing = (await loadLocalSettings(path).catch(() => null)) ?? {};
1442-
const next: LocalSettings = { ...existing, sessionMode: mode };
1443-
await saveLocalSettings(path, next);
1504+
// Absent → {}; unreadable/invalid → null so we never clobber.
1505+
const existing = await loadLocalSettingsWriteBase(path);
1506+
if (existing === null) {
1507+
tuiLogger.warn(
1508+
"Skipping local session mode write: unreadable settings at {path}",
1509+
{ path },
1510+
);
1511+
return;
1512+
}
1513+
await saveLocalSettings(path, { ...existing, sessionMode: mode });
14441514
} else {
1445-
const current = await loadSettings(config.globalSettingsPath).catch(() => null);
1446-
const base: Settings = current ?? { providers: {} };
1515+
const base = await loadGlobalSettingsWriteBase(config.globalSettingsPath);
1516+
if (base === null) {
1517+
tuiLogger.warn(
1518+
"Skipping global session mode write: unreadable settings at {path}",
1519+
{ path: config.globalSettingsPath },
1520+
);
1521+
return;
1522+
}
14471523
await saveGlobalSettings(config.globalSettingsPath, { ...base, sessionMode: mode });
14481524
}
14491525
}}
@@ -1496,7 +1572,17 @@ export async function runTUI(initialConfig: Config): Promise<number> {
14961572
const blocks = turnsToContentBlocks(turns, { maxBlocks: RESUME_TRANSCRIPT_BLOCK_LIMIT });
14971573
if (blocks.length > 0) emitter.emit("history.hydrate", blocks);
14981574
})
1499-
.catch(() => undefined);
1575+
.catch((err: unknown) => {
1576+
// Resume still works without painted history, but a silent empty
1577+
// transcript looks like a brand-new session. Log and surface a one-line
1578+
// error block so the operator knows history failed to load.
1579+
const block = resumeTranscriptLoadErrorBlock(err);
1580+
tuiLogger.warn("Failed to load resume transcript from {workdir}: {error}", {
1581+
workdir,
1582+
error: err instanceof Error ? err.message : String(err),
1583+
});
1584+
emitter.emit("history.hydrate", [block]);
1585+
});
15001586

15011587
// Connect MCP servers after the TUI is up so the UI is usable immediately and
15021588
// any OAuth authorization is surfaced as a copyable link rather than a browser

tests/unit/exec/runner.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, test } from "bun:test";
22
import type { Config } from "../../../src/config/index.js";
3-
import { runExec } from "../../../src/exec/runner.js";
3+
import { formatCaughtError, runExec } from "../../../src/exec/runner.js";
44

55
function bareConfig(task: string): Config {
66
// Minimal unconfigured-shaped object is not enough — runExec only needs
@@ -20,6 +20,14 @@ function bareConfig(task: string): Config {
2020
} as unknown as Config;
2121
}
2222

23+
describe("formatCaughtError", () => {
24+
test("prefers Error.message and stringifies other values", () => {
25+
expect(formatCaughtError(new Error("disk full"))).toBe("disk full");
26+
expect(formatCaughtError("plain")).toBe("plain");
27+
expect(formatCaughtError(42)).toBe("42");
28+
});
29+
});
30+
2331
describe("runExec", () => {
2432
test("empty prompt exits 2 with stderr message without bootstrapping", async () => {
2533
const stderrChunks: string[] = [];

tests/unit/tui/runner.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { test, expect } from "bun:test";
22
import { EventEmitter } from "node:events";
3-
import { createTUIEventEmitter, getTUIRunSummaryStatus } from "../../../src/tui/runner.js";
3+
import {
4+
createTUIEventEmitter,
5+
getTUIRunSummaryStatus,
6+
loadLocalSettingsWriteBase,
7+
resumeTranscriptLoadErrorBlock,
8+
} from "../../../src/tui/runner.js";
49
import { createRunSink } from "../../../src/session/run-sink.js";
510

611
test("createTUIEventEmitter returns an EventEmitter", () => {
@@ -22,6 +27,32 @@ test("getTUIRunSummaryStatus distinguishes done, failed, and cancelled runs", ()
2227
expect(getTUIRunSummaryStatus(false, undefined)).toBe("cancelled");
2328
});
2429

30+
test("resumeTranscriptLoadErrorBlock surfaces a user-visible error block", () => {
31+
expect(resumeTranscriptLoadErrorBlock(new Error("EACCES"))).toEqual({
32+
type: "error",
33+
message: "Could not load prior session transcript: EACCES",
34+
});
35+
expect(resumeTranscriptLoadErrorBlock("disk full").message).toContain("disk full");
36+
});
37+
38+
test("loadLocalSettingsWriteBase distinguishes absent from unreadable", async () => {
39+
// Absent → empty base (safe to write a single key).
40+
expect(await loadLocalSettingsWriteBase("/nope", async () => null)).toEqual({});
41+
42+
// Readable → merge base.
43+
expect(
44+
await loadLocalSettingsWriteBase("/ok", async () => ({ sessionMode: "single" })),
45+
).toEqual({ sessionMode: "single" });
46+
47+
// Unreadable/invalid → null so the caller skips the write instead of
48+
// overwriting the file with only sessionMode.
49+
expect(
50+
await loadLocalSettingsWriteBase("/bad", async () => {
51+
throw new Error("invalid schema");
52+
}),
53+
).toBeNull();
54+
});
55+
2556
// Rotation behavioral tests — per-session store semantics without a real TUI or agent.
2657

2758
// When buildAgent throws after the old agent is closed, fatalBuildError must

0 commit comments

Comments
 (0)