diff --git a/src/cost/pricing-metadata.ts b/src/cost/pricing-metadata.ts index 89a562954..165ec3a10 100644 --- a/src/cost/pricing-metadata.ts +++ b/src/cost/pricing-metadata.ts @@ -31,7 +31,10 @@ export function schedulePricingMetadataRefresh(options: PricingFetcherOptions = .then((cache) => { if (cache !== null) applyPricingCacheMetadata(cache); }) - .catch(() => undefined); + .catch((err: unknown) => { + // Match pricing-fetcher: keep refresh best-effort, surface the failure. + process.stderr.write(`pricing-metadata: refresh error: ${err}\n`); + }); } export async function bootstrapPricingMetadata(options: PricingFetcherOptions = {}): Promise { diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 6b0442cef..35b3554cf 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -95,6 +95,11 @@ import { getToolApprovalBudget } from "../tui/tool-execution-watchdog.js"; const logger = getLogger([LOG_NAMESPACE_ROOT, "exec"]); +/** Normalize unknown catch values for structured warn/error logs. */ +export function formatCaughtError(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + /** Content-less inbound used after compact so the reactor re-enters (matches TUI). */ function buildCompactionContinuationMessage(): InboundMessage { return { @@ -194,7 +199,15 @@ export async function runExec(config: Config): Promise { mcpServers: connectedMcp, ...(status !== "running" ? { finishedAt: Date.now() } : {}), ...(extra?.error !== undefined ? { error: extra.error } : {}), - }).catch(() => undefined); + }).catch((err: unknown) => { + // Persistence failure must not fail the run, but dropping it silently + // hides disk/permission problems that leave run.json stale. + logger.warn("saveState failed for session {sessionId} status={status}: {error}", { + sessionId, + status, + error: formatCaughtError(err), + }); + }); }; @@ -204,7 +217,12 @@ export async function runExec(config: Config): Promise { try { const inferenceDeps = await createInferenceDependencies(); await seedPricingMetadataFromCache({ cachePath: defaultPricingCachePath() }).catch( - () => undefined, + (err: unknown) => { + // Pricing seed is optional for exec; continue without rates rather than fail the run. + logger.debug("seedPricingMetadataFromCache failed: {error}", { + error: formatCaughtError(err), + }); + }, ); let projectTrust = await loadProjectTrust(config.cwd); @@ -243,8 +261,14 @@ export async function runExec(config: Config): Promise { const profilesDir = join(config.cwd, ".agents", "agents"); const liveAgentProfiles = await loadAgentProfiles(profilesDir); + // loadLocalSettings maps ENOENT → null; a throw is real I/O or schema failure. const localSettingsForMode = await loadLocalSettings(localSettingsPath(config.cwd)).catch( - () => null, + (err: unknown) => { + logger.warn("Failed to load local settings: {error}", { + error: formatCaughtError(err), + }); + return null; + }, ); const sessionMode: SessionMode = resolveSessionMode(config.settings, localSettingsForMode) ?? "orchestrator"; @@ -590,8 +614,16 @@ export async function runExec(config: Config): Promise { // the entire successful reply as a spurious partial. After the drain, // dispose is a no-op on success and a real record only if a cycle // died without a terminal event. - await activeAgent.close().catch(() => undefined); - await streamPromise.catch(() => undefined); + await activeAgent.close().catch((err: unknown) => { + logger.debug("agent.close during successful-send teardown failed: {error}", { + error: formatCaughtError(err), + }); + }); + await streamPromise.catch((err: unknown) => { + logger.debug("stream drain during successful-send teardown failed: {error}", { + error: formatCaughtError(err), + }); + }); await cycleRecorder.dispose("cancelled"); } else { // Failed or aborted send: close() tears down stream consumers before @@ -599,8 +631,16 @@ export async function runExec(config: Config): Promise { // snapshots the buffer at entry) runs before closing or the text is // lost. await cycleRecorder.dispose(sendCompleted ? "cancelled" : "send-failed"); - await activeAgent.close().catch(() => undefined); - await streamPromise.catch(() => undefined); + await activeAgent.close().catch((err: unknown) => { + logger.debug("agent.close during failed-send teardown failed: {error}", { + error: formatCaughtError(err), + }); + }); + await streamPromise.catch((err: unknown) => { + logger.debug("stream drain during failed-send teardown failed: {error}", { + error: formatCaughtError(err), + }); + }); } } @@ -630,7 +670,13 @@ export async function runExec(config: Config): Promise { toolCallCount: runSink.getToolCallCount(), ...(runError !== undefined ? { error: runError } : {}), }); - await hookManager.dispatchPostRun(runSummary).catch(() => undefined); + await hookManager.dispatchPostRun(runSummary).catch((err: unknown) => { + // Post-run hooks are best-effort; keep the exec exit path intact but + // surface the failure so operators can see hook/script problems. + const message = formatCaughtError(err); + logger.warn("dispatchPostRun failed: {error}", { error: message }); + stderr.write(`Warning: post-run hook failed: ${message}\n`); + }); if (!sendCompleted || runError !== undefined || summaryStatus === "failed") { const message = @@ -688,11 +734,19 @@ export async function runExec(config: Config): Promise { }; } finally { if (agent !== null) { - await agent.close().catch(() => undefined); + await agent.close().catch((err: unknown) => { + logger.debug("agent.close during exec finally failed: {error}", { + error: formatCaughtError(err), + }); + }); } // Match TUI: always dispose toolset (MCP clients + posix/plugin resources). if (toolset !== null) { - await toolset.dispose().catch(() => undefined); + await toolset.dispose().catch((err: unknown) => { + logger.debug("toolset.dispose during exec finally failed: {error}", { + error: formatCaughtError(err), + }); + }); } } } diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 810d5ba9d..281ab00de 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -109,7 +109,16 @@ async function writeProfileFile(dir: string, profile: AgentProfile): Promise { - await unlink(`${dir}/${id}.json`).catch(() => {}); + try { + await unlink(`${dir}/${id}.json`); + } catch (err: unknown) { + // Missing file is the common case (already deleted / never written). + if ((err as NodeJS.ErrnoException).code === "ENOENT") return; + getLogger([LOG_NAMESPACE_ROOT, "tui", "profiles"]).warn( + "Failed to delete agent profile {id}: {error}", + { id, error: err instanceof Error ? err.message : String(err) }, + ); + } } export type AppProps = { diff --git a/src/tui/hooks/use-provider-auth.ts b/src/tui/hooks/use-provider-auth.ts index de31c2bac..8ef5ea089 100644 --- a/src/tui/hooks/use-provider-auth.ts +++ b/src/tui/hooks/use-provider-auth.ts @@ -1,4 +1,5 @@ import { useMemo, useState, type Dispatch, type SetStateAction } from "react"; +import { getLogger } from "@intx/log"; import { getValidCodexToken, CodexAuthError } from "../../auth/codex/session.js"; import { refreshCodexInstructions } from "../../auth/codex/instructions.js"; import { removeCodexProfile } from "../../auth/codex/store.js"; @@ -10,6 +11,9 @@ import { codexProviderName, codexProfileFromProviderName } from "../../config/co import { xaiProviderName, xaiProfileFromProviderName } from "../../config/xai-providers.js"; import { fetchCodexModels } from "../../auth/codex/usage.js"; import type { ProviderCatalogEntry } from "../../config/index.js"; +import { LOG_NAMESPACE_ROOT } from "../../branding.js"; + +const logger = getLogger([LOG_NAMESPACE_ROOT, "tui", "provider-auth"]); export type LoginModal = "codex" | "xai" | "choose" | null; @@ -137,8 +141,24 @@ export function useProviderAuth({ }; const switchToCodexProfile = (name: string): void => { - void refreshCodexInstructions().catch(() => {}); - void Promise.all([getValidCodexToken(name), fetchCodexModels(name).catch(() => [])]).then( + void refreshCodexInstructions().catch((err: unknown) => { + // Best-effort prompt refresh; profile switch still proceeds with cached text. + logger.warn("Codex instructions refresh failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + void Promise.all([ + getValidCodexToken(name), + fetchCodexModels(name).catch((err: unknown) => { + // Empty catalog falls back to CODEX_DEFAULT_MODELS below; log so + // rate-limit/network failures are visible while switching profiles. + logger.debug("Codex models fetch failed for profile {profile}; using defaults: {error}", { + profile: name, + error: err instanceof Error ? err.message : String(err), + }); + return []; + }), + ]).then( ([token, liveModels]) => { const accountId = token.accountId; // Prefer the account's live model catalog; fall back to the current diff --git a/src/tui/runner.tsx b/src/tui/runner.tsx index bce9a33ab..b39a0bb94 100644 --- a/src/tui/runner.tsx +++ b/src/tui/runner.tsx @@ -135,7 +135,9 @@ import { } from "../session/runtime-assembly.js"; import { createAttachmentRehydrateTransform } from "../session/attachment-store.js"; import { createModelSummarizer } from "../session/summarizer.js"; -import { ID_PREFIX, LOG_NAMESPACE_ROOT } from "../branding.js"; +import { COMMAND_NAME, ID_PREFIX, LOG_NAMESPACE_ROOT } from "../branding.js"; + +const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); export function createTUIEventEmitter(): EventEmitter { return new EventEmitter(); @@ -157,6 +159,30 @@ export function resolveExitCode(args: ResolveExitCodeArgs): number { return 0; } +/** One-line transcript block when resume history fails to load. */ +export function resumeTranscriptLoadErrorBlock(err: unknown): { + type: "error"; + message: string; +} { + const message = err instanceof Error ? err.message : String(err); + return { type: "error", message: `Could not load prior session transcript: ${message}` }; +} + +/** + * Resolve the base for a local-settings read-modify-write. + * Absent file → empty object; unreadable/invalid → null (caller must skip write). + */ +export async function loadLocalSettingsWriteBase( + path: string, + load: (path: string) => Promise = loadLocalSettings, +): Promise { + try { + return (await load(path)) ?? {}; + } catch { + return null; + } +} + function buildCompactionContinuationMessage(): InboundMessage { return { ref: { uid: 0, mailbox: "system" }, @@ -271,7 +297,13 @@ export async function runTUI(initialConfig: Config): Promise { const finalizeOnCrash = async (err: unknown): Promise => { if (finalized) return; finalized = true; - await flushPartialOnCrash().catch(() => undefined); + await flushPartialOnCrash().catch((flushErr: unknown) => { + // Best-effort only — still attempt saveState below. Log so a flush + // failure is not invisible when diagnosing a crash exit. + const flushMessage = flushErr instanceof Error ? flushErr.message : String(flushErr); + tuiLogger.warn("crash finalize: partial flush failed: {error}", { error: flushMessage }); + process.stderr.write(`${COMMAND_NAME}: crash finalize partial flush failed: ${flushMessage}\n`); + }); const message = err instanceof Error ? err.message : String(err); await saveState(config.cwd, sessionId, { status: "failed", @@ -282,7 +314,16 @@ export async function runTUI(initialConfig: Config): Promise { error: message, model: `${config.providerName}:${config.model}`, mcpServers: [], - }).catch(() => undefined); + }).catch((saveErr: unknown) => { + const saveMessage = saveErr instanceof Error ? saveErr.message : String(saveErr); + tuiLogger.warn( + "crash finalize: saveState failed for session {sessionId}: {error}", + { sessionId, error: saveMessage }, + ); + process.stderr.write( + `${COMMAND_NAME}: crash finalize saveState failed for ${sessionId}: ${saveMessage}\n`, + ); + }); }; try { @@ -412,8 +453,16 @@ export async function runTUI(initialConfig: Config): Promise { let liveWebOverride: string | undefined = config.settings?.web; const livePluginPaths: string[] = [...(config.settings?.pluginPaths ?? [])]; const persistPluginSettings = async (): Promise => { - const current = await loadSettings(config.globalSettingsPath).catch(() => null); - const base: Settings = current ?? { providers: {} }; + // Absent file → fresh base; unreadable/invalid → skip write so we never + // clobber a corrupt settings file by rewriting from a minimal shell. + const base = await loadGlobalSettingsWriteBase(config.globalSettingsPath); + if (base === null) { + tuiLogger.warn( + "Skipping plugin settings write: unreadable global settings at {path}", + { path: config.globalSettingsPath }, + ); + return; + } const next: Settings = { ...base, plugins: livePluginConfig }; if (livePluginPaths.length > 0) next.pluginPaths = livePluginPaths; else delete next.pluginPaths; @@ -638,7 +687,15 @@ export async function runTUI(initialConfig: Config): Promise { const picked = await promptSessionModeIfUnset(config.globalSettingsPath); liveSessionMode = picked ?? "orchestrator"; if (picked !== undefined) { - const refreshed = await loadSettings(config.globalSettingsPath).catch(() => null); + const refreshed = await loadSettings(config.globalSettingsPath).catch((err: unknown) => { + // loadSettings already maps ENOENT → null; a throw is a real I/O or + // schema failure. Keep the in-memory config rather than pretending + // settings are empty. + tuiLogger.warn("Failed to reload settings after session mode pick: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + return null; + }); if (refreshed !== null) config = { ...config, settings: refreshed }; } } @@ -809,7 +866,14 @@ export async function runTUI(initialConfig: Config): Promise { // the run) rather than the OpenAI-compatible one. const initialCodexProfile = codexProfileFromProviderName(config.providerName); const initialXaiProfile = xaiProfileFromProviderName(config.providerName); - if (initialCodexProfile !== undefined) void refreshCodexInstructions().catch(() => {}); + if (initialCodexProfile !== undefined) { + void refreshCodexInstructions().catch((err: unknown) => { + // Best-effort; agent still starts with cached/default instructions. + tuiLogger.warn("Codex instructions refresh failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + } const initialCodexAccountId = config.providers.find((p) => p.name === config.providerName)?.codexAccountId; const buildOpenAICompatibleInitialSource = (): InferenceSource => buildOpenAISource({ @@ -1081,8 +1145,16 @@ export async function runTUI(initialConfig: Config): Promise { pendingReload = false; void enqueueOp(async () => { const old = currentAgent; - await old.close().catch(() => undefined); - await streamPromise.catch(() => undefined); + await old.close().catch((err: unknown) => { + tuiLogger.debug("agent.close during reload teardown failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + await streamPromise.catch((err: unknown) => { + tuiLogger.debug("stream drain during reload teardown failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); currentAgent = await buildAgent(); streamPromise = consumeStream(currentAgent.stream(), streamSink); // The rebuild made a fresh director; re-attach the active workflow. @@ -1218,8 +1290,16 @@ export async function runTUI(initialConfig: Config): Promise { // and salvages the buffer before that teardown, so it is never lost // or misattributed to the rebuilt agent's next cycle. await cycleRecorder.dispose("interrupted"); - await currentAgent.close().catch(() => undefined); - await streamPromise.catch(() => undefined); + await currentAgent.close().catch((err: unknown) => { + tuiLogger.debug("agent.close during interrupt teardown failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + await streamPromise.catch((err: unknown) => { + tuiLogger.debug("stream drain during interrupt teardown failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); currentAgent = await buildAgent(); cycleRecorder.reset(); streamPromise = consumeStream(currentAgent.stream(), streamSink); @@ -1252,8 +1332,16 @@ export async function runTUI(initialConfig: Config): Promise { // settles, and a dead cycle's partial must land in the session that // produced it, not the fresh one. await cycleRecorder.dispose("rotation"); - await currentAgent.close().catch(() => undefined); - await streamPromise.catch(() => undefined); + await currentAgent.close().catch((err: unknown) => { + tuiLogger.debug("agent.close during session-rotation teardown failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + await streamPromise.catch((err: unknown) => { + tuiLogger.debug("stream drain during session-rotation teardown failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); await persistRunSnapshot("done", { finishedAt: Date.now() }); sessionId = generateSessionId(); startedAt = Date.now(); @@ -1409,14 +1497,26 @@ export async function runTUI(initialConfig: Config): Promise { {...(config.settings !== undefined ? { initialSettings: config.settings } : {})} onChangeCompactionMode={async (mode) => { liveCompactionMode = mode; - const current = await loadSettings(config.globalSettingsPath).catch(() => null); - const base: Settings = current ?? { providers: {} }; + const base = await loadGlobalSettingsWriteBase(config.globalSettingsPath); + if (base === null) { + tuiLogger.warn( + "Skipping compaction mode write: unreadable global settings at {path}", + { path: config.globalSettingsPath }, + ); + return; + } await saveGlobalSettings(config.globalSettingsPath, { ...base, compactionMode: mode }); }} onChangeMaxConcurrentSubAgents={async (limit) => { configureSubAgentConcurrency(limit); const base = await loadGlobalSettingsWriteBase(config.globalSettingsPath); - if (base === null) return; + if (base === null) { + tuiLogger.warn( + "Skipping max concurrent sub-agents write: unreadable global settings at {path}", + { path: config.globalSettingsPath }, + ); + return; + } await saveGlobalSettings(config.globalSettingsPath, { ...base, maxConcurrentSubAgents: limit, @@ -1426,7 +1526,13 @@ export async function runTUI(initialConfig: Config): Promise { onChangeWaitForApproval={async (value) => { liveToolWatchdog.waitForApproval = value; const base = await loadGlobalSettingsWriteBase(config.globalSettingsPath); - if (base === null) return; + if (base === null) { + tuiLogger.warn( + "Skipping wait-for-approval write: unreadable global settings at {path}", + { path: config.globalSettingsPath }, + ); + return; + } await saveGlobalSettings(config.globalSettingsPath, { ...base, tools: { ...base.tools, waitForApproval: value }, @@ -1442,15 +1548,25 @@ export async function runTUI(initialConfig: Config): Promise { onChangeSessionMode={async (mode, scope) => { if (scope === "local") { const path = localSettingsPath(config.cwd); - const base = await loadLocalSettingsWriteBase(path); - // Skip write when the file exists but is unreadable/unusable so we - // never wipe a broken selection down to only sessionMode. - if (base === null) return; - const next: LocalSettings = { ...base, sessionMode: mode }; - await saveLocalSettings(path, next); +// Absent → {}; unreadable/invalid → null so we never clobber. + const existing = await loadLocalSettingsWriteBase(path); + if (existing === null) { + tuiLogger.warn( + "Skipping local session mode write: unreadable settings at {path}", + { path }, + ); + return; + } + await saveLocalSettings(path, { ...existing, sessionMode: mode }); } else { - const current = await loadSettings(config.globalSettingsPath).catch(() => null); - const base: Settings = current ?? { providers: {} }; + const base = await loadGlobalSettingsWriteBase(config.globalSettingsPath); + if (base === null) { + tuiLogger.warn( + "Skipping global session mode write: unreadable settings at {path}", + { path: config.globalSettingsPath }, + ); + return; + } await saveGlobalSettings(config.globalSettingsPath, { ...base, sessionMode: mode }); } }} @@ -1502,7 +1618,17 @@ export async function runTUI(initialConfig: Config): Promise { const blocks = turnsToContentBlocks(turns, { maxBlocks: RESUME_TRANSCRIPT_BLOCK_LIMIT }); if (blocks.length > 0) emitter.emit("history.hydrate", blocks); }) - .catch(() => undefined); + .catch((err: unknown) => { + // Resume still works without painted history, but a silent empty + // transcript looks like a brand-new session. Log and surface a one-line + // error block so the operator knows history failed to load. + const block = resumeTranscriptLoadErrorBlock(err); + tuiLogger.warn("Failed to load resume transcript from {workdir}: {error}", { + workdir, + error: err instanceof Error ? err.message : String(err), + }); + emitter.emit("history.hydrate", [block]); + }); // Connect MCP servers after the TUI is up so the UI is usable immediately and // any OAuth authorization is surfaced as a copyable link rather than a browser diff --git a/tests/unit/exec/runner.test.ts b/tests/unit/exec/runner.test.ts index ac74eb98d..00b828579 100644 --- a/tests/unit/exec/runner.test.ts +++ b/tests/unit/exec/runner.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { Config } from "../../../src/config/index.js"; -import { runExec } from "../../../src/exec/runner.js"; +import { formatCaughtError, runExec } from "../../../src/exec/runner.js"; function bareConfig(task: string): Config { // Minimal unconfigured-shaped object is not enough — runExec only needs @@ -20,6 +20,14 @@ function bareConfig(task: string): Config { } as unknown as Config; } +describe("formatCaughtError", () => { + test("prefers Error.message and stringifies other values", () => { + expect(formatCaughtError(new Error("disk full"))).toBe("disk full"); + expect(formatCaughtError("plain")).toBe("plain"); + expect(formatCaughtError(42)).toBe("42"); + }); +}); + describe("runExec", () => { test("empty prompt exits 2 with stderr message without bootstrapping", async () => { const stderrChunks: string[] = []; diff --git a/tests/unit/tui/runner.test.ts b/tests/unit/tui/runner.test.ts index 14013aa73..8ae66c3e6 100644 --- a/tests/unit/tui/runner.test.ts +++ b/tests/unit/tui/runner.test.ts @@ -1,6 +1,11 @@ import { test, expect } from "bun:test"; import { EventEmitter } from "node:events"; -import { createTUIEventEmitter, getTUIRunSummaryStatus } from "../../../src/tui/runner.js"; +import { + createTUIEventEmitter, + getTUIRunSummaryStatus, + loadLocalSettingsWriteBase, + resumeTranscriptLoadErrorBlock, +} from "../../../src/tui/runner.js"; import { createRunSink } from "../../../src/session/run-sink.js"; test("createTUIEventEmitter returns an EventEmitter", () => { @@ -22,6 +27,32 @@ test("getTUIRunSummaryStatus distinguishes done, failed, and cancelled runs", () expect(getTUIRunSummaryStatus(false, undefined)).toBe("cancelled"); }); +test("resumeTranscriptLoadErrorBlock surfaces a user-visible error block", () => { + expect(resumeTranscriptLoadErrorBlock(new Error("EACCES"))).toEqual({ + type: "error", + message: "Could not load prior session transcript: EACCES", + }); + expect(resumeTranscriptLoadErrorBlock("disk full").message).toContain("disk full"); +}); + +test("loadLocalSettingsWriteBase distinguishes absent from unreadable", async () => { + // Absent → empty base (safe to write a single key). + expect(await loadLocalSettingsWriteBase("/nope", async () => null)).toEqual({}); + + // Readable → merge base. + expect( + await loadLocalSettingsWriteBase("/ok", async () => ({ sessionMode: "single" })), + ).toEqual({ sessionMode: "single" }); + + // Unreadable/invalid → null so the caller skips the write instead of + // overwriting the file with only sessionMode. + expect( + await loadLocalSettingsWriteBase("/bad", async () => { + throw new Error("invalid schema"); + }), + ).toBeNull(); +}); + // Rotation behavioral tests — per-session store semantics without a real TUI or agent. // When buildAgent throws after the old agent is closed, fatalBuildError must