diff --git a/docs/reference/orcad-operations.md b/docs/reference/orcad-operations.md index 63f03777c17..adc18586c21 100644 --- a/docs/reference/orcad-operations.md +++ b/docs/reference/orcad-operations.md @@ -165,7 +165,12 @@ An external supervisor (systemd, launchd, a process manager). orcad conforms to to **stdout**; the supervisor owns capture and rotation. The daemon, being detached, writes its own NDJSON lifecycle log to `/logs/daemon.log` (suppressed by `ORCA_DIAGNOSTICS_DISABLED=1`). Rotation of that file is not implemented — see - [What is not covered](#what-is-not-covered). + [What is not covered](#what-is-not-covered). orcad records every trace span it emits + (git commands, worktree paths, terminal spawns, structured-chat failures and the rest) to + `/logs/orcad.trace.ndjson`, rotated at 10 MB × 10 files, private to its user and + redacted for secret-shaped strings. It stays on the host: a desktop's diagnostics bundle does + not collect it. `ORCA_DIAGNOSTICS_DISABLED=1` turns it off, and a logs folder orcad cannot + open leaves it off with one stderr warning rather than stopping orcad. ### orcad supervising the daemon diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 63b6165ce3a..0e17feb9874 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -63,6 +63,7 @@ import { noUpstreamError, workingEvent } from './first-work-branch-rename-test-harness' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' function makeDeps(overrides: Partial = {}) { return makeBranchRenameDeps(vi.fn, overrides) @@ -117,6 +118,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { } }) const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([ [ 'session', @@ -215,6 +217,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { } const pending: Promise[] = [] const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([['session', { journal, params: { location, provider: 'codex' } }]]), getRecord: () => null, now: () => 1, diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index bac1da60fe7..664f9c135da 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -18,6 +18,7 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const SWEEP_MS = 5 const RETRY_GAP_MS = 10 * 60_000 @@ -77,6 +78,7 @@ beforeEach(async () => { setOption: async () => undefined } host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter, journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/claude/claude-api-retry-row.test.ts b/src/main/claude/claude-api-retry-row.test.ts index a1c8da60cb7..843fbb281d3 100644 --- a/src/main/claude/claude-api-retry-row.test.ts +++ b/src/main/claude/claude-api-retry-row.test.ts @@ -12,6 +12,7 @@ import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/ag import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -54,7 +55,7 @@ async function statusRowsFor(frames: Record[]) { now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-1' }) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind({ journal, fence: 1, publish: vi.fn() }) const translator = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: '1' }) for (const frame of frames) { diff --git a/src/main/claude/claude-background-wake-retention.test.ts b/src/main/claude/claude-background-wake-retention.test.ts index 313f9c5de59..a865e1c1b76 100644 --- a/src/main/claude/claude-background-wake-retention.test.ts +++ b/src/main/claude/claude-background-wake-retention.test.ts @@ -15,6 +15,7 @@ import { backgroundWakeCapture } from './claude-captured-fold-steer-frames.test- import { MOVED_TO_BACKGROUND } from './claude-captured-task-frames.test-fixture' import { hostWithParent, parent, producer } from './claude-child-work-producer-harness.test-fixture' import { PROVIDER_SESSION_ID } from './claude-structured-session-test-support' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) })) @@ -40,6 +41,7 @@ async function wiredSession() { }) } const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([ [ parent.sessionId, diff --git a/src/main/claude/claude-context-usage-restart.test.ts b/src/main/claude/claude-context-usage-restart.test.ts index 4359799272e..be2c1569ead 100644 --- a/src/main/claude/claude-context-usage-restart.test.ts +++ b/src/main/claude/claude-context-usage-restart.test.ts @@ -10,6 +10,7 @@ import { settleStaleStructuredAgentSessionState } from '../native-chat/agent-ses import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { bindClaudeContextUsageCapture } from './claude-context-usage' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const journals = createTrackedJournalOpener() let root: string @@ -112,7 +113,7 @@ async function openJournal(): Promise { /** One acquisition: a fresh sink and translator over the session's journal. */ function acquire(journal: AgentSessionJournal) { - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const translator = createClaudeJournalTranslator({ sink: deferred.sink, coalesceMs: 0 }) deferred.bind({ journal, fence: 1, publish: () => {} }) const answers: ((value: unknown) => void)[] = [] diff --git a/src/main/claude/claude-context-usage-unloaded-turn.test.ts b/src/main/claude/claude-context-usage-unloaded-turn.test.ts index ed76a788c18..d98ef7583df 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -27,6 +27,7 @@ import { userFrame } from './claude-context-usage-test-support' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const SESSION = 'orca-session' const journals = createTrackedJournalOpener() @@ -56,7 +57,7 @@ async function openJournal(): Promise { } function translate(journal: AgentSessionJournal) { - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const translator = createClaudeJournalTranslator({ sink: deferred.sink, coalesceMs: 0 }) deferred.bind({ journal, fence: 1, publish: () => {} }) const settle = async (): Promise => { diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 7d198c5a5de..082ed4c12ce 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -21,6 +21,7 @@ import { readClaudeStructuredSessionOptions } from './claude-structured-session- import type { ClaudeSession } from './claude-structured-session-state' import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' // These drive the real SDK against the scripted fake CLI, so every assertion is // about the environment, argv and frames a real child actually saw. @@ -385,7 +386,7 @@ describe('Claude stream-json connection', () => { now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-1' }) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind({ journal, fence: 1, publish: vi.fn() }) const translator = createClaudeJournalTranslator({ sink: deferred.sink }) let settled = false diff --git a/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts b/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts index c8a9e014950..db3b75ecf32 100644 --- a/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts +++ b/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts @@ -15,6 +15,7 @@ import { import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { blockOf } from './claude-background-task-row-test-support' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' // The frames below are the ones the reported session actually carried: two real // failures that printed THREE red rows whose visible text was the wire opcode, @@ -179,6 +180,7 @@ describe('claude journal translation — background task rows', () => { return appendItem(...args) }) const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(), watermarks: { pauseQueuedOperations: 1, maxQueuedOperations: 4, @@ -252,7 +254,7 @@ describe('claude journal translation — background task rows', () => { it('coalesces an unbound overflow patch and aliased final notification', async () => { const persisted = new Map() - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const translator = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: 'test' @@ -292,7 +294,7 @@ describe('claude journal translation — background task rows', () => { it('reconciles an aliased notification after a parentless overflow patch was persisted', async () => { const persisted = new Map() - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind(persistedTarget(persisted)) const first = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: 'first' }) fillLiveTaskRows(first) @@ -353,7 +355,7 @@ describe('claude journal translation — background task rows', () => { it('resolves a queued restart identity after the sink rebinds', async () => { const persisted = new Map() - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind(persistedTarget(persisted)) const first = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: 'first' }) @@ -371,7 +373,7 @@ describe('claude journal translation — background task rows', () => { first.dispose() await deferred.drained() - const restarted = createDeferredStructuredAgentSessionEventSink() + const restarted = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const second = createClaudeJournalTranslator({ sink: restarted.sink, fallbackIdPrefix: 'second' @@ -397,7 +399,7 @@ describe('claude journal translation — background task rows', () => { it('keeps pending writes from distinct runs when a translator is recreated', async () => { const persisted = new Map() - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const first = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: 'first' }) spawnToolCall(first, 'toolu-first') diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index 1b03881e471..0cf7b30368a 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -23,6 +23,7 @@ import type { ClaudePendingPrompt } from './claude-structured-prompt-replies' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' function sinkState() { const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] @@ -258,7 +259,7 @@ describe('Claude structured journal translation', () => { now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-1' }) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind({ journal, fence: 1, publish: vi.fn() }) let scheduled: (() => void) | null = null const translator = createClaudeJournalTranslator({ @@ -317,7 +318,7 @@ describe('Claude structured journal translation', () => { now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-1' }) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind({ journal, fence: 1, publish: vi.fn() }) const translator = createClaudeJournalTranslator({ sink: deferred.sink }) const approval = prompt({ diff --git a/src/main/claude/claude-structured-provider-fallback.test.ts b/src/main/claude/claude-structured-provider-fallback.test.ts index a3a47c8bea8..4cae8b465e9 100644 --- a/src/main/claude/claude-structured-provider-fallback.test.ts +++ b/src/main/claude/claude-structured-provider-fallback.test.ts @@ -11,6 +11,7 @@ import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/ag import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -80,7 +81,7 @@ describe('Claude provider fallback', () => { now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-1' }) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind({ journal, fence: 1, diff --git a/src/main/claude/claude-structured-session-reading-control.test.ts b/src/main/claude/claude-structured-session-reading-control.test.ts index d89bd1feb09..d90e5538964 100644 --- a/src/main/claude/claude-structured-session-reading-control.test.ts +++ b/src/main/claude/claude-structured-session-reading-control.test.ts @@ -20,6 +20,7 @@ import { identityFor, PROVIDER_SESSION_ID } from './claude-structured-session-test-support' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' function controlledSink(): { sink: StructuredAgentSessionEventSink @@ -212,6 +213,7 @@ describe('Claude structured reading control', () => { const persisted = new Map() const target = persistedTarget(persisted) const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(), watermarks: { pauseQueuedOperations: 1, maxQueuedOperations: 4, diff --git a/src/main/claude/claude-subagent-resume-after-restart.test.ts b/src/main/claude/claude-subagent-resume-after-restart.test.ts index ef6c067db9e..c4b848eee44 100644 --- a/src/main/claude/claude-subagent-resume-after-restart.test.ts +++ b/src/main/claude/claude-subagent-resume-after-restart.test.ts @@ -16,6 +16,7 @@ import { createTrackedJournalOpener } from '../native-chat/agent-session-journal import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { claudeSubagentGroupBody, claudeSubagentGroupIdentity } from './claude-subagent-group-row' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' // Frame orders are real sessions', scrubbed. A resumed agent's frames still name its ORIGINAL // spawn call while the announcement names the message call that resumed it, and a new provider @@ -137,7 +138,7 @@ async function openJournal(): Promise { /** One provider process: a fresh sink and translator over the session's journal. */ function acquire(journal: AgentSessionJournal) { - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const translator = createClaudeJournalTranslator({ sink: deferred.sink, coalesceMs: 0 }) deferred.bind({ journal, fence: 1, publish: () => {} }) const settle = async (): Promise => { @@ -190,7 +191,7 @@ const rosterIds = (journal: AgentSessionJournal): Set => /** An older build re-rostered a resumed child in the later turn's row, so two rows list it. */ async function journalAnOlderBuildListedTwice(): Promise { const journal = await openJournal() - const older = createDeferredStructuredAgentSessionEventSink() + const older = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) older.bind({ journal, fence: 1, publish: () => {} }) const listed = (id: string, state: NativeChatSubagentEntry['state']) => ({ id, diff --git a/src/main/claude/claude-subagent-roster.test.ts b/src/main/claude/claude-subagent-roster.test.ts index 34ff506ee03..2bcffcfb49f 100644 --- a/src/main/claude/claude-subagent-roster.test.ts +++ b/src/main/claude/claude-subagent-roster.test.ts @@ -14,6 +14,7 @@ import { type StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { ClaudeSubagentRoster } from './claude-subagent-roster' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const TURN_1 = 'claude-session:turn-1' @@ -462,7 +463,7 @@ describe('ClaudeSubagentRoster — through the real sink queue', () => { }, appendTombstone: async () => ({ epoch: 'e', sequence: 0 }) } as unknown as AgentSessionJournal - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind({ journal, fence: 1, diff --git a/src/main/codex/codex-provider-retry-idle-sweep.test.ts b/src/main/codex/codex-provider-retry-idle-sweep.test.ts index b1e8b8b6510..39291b11b19 100644 --- a/src/main/codex/codex-provider-retry-idle-sweep.test.ts +++ b/src/main/codex/codex-provider-retry-idle-sweep.test.ts @@ -18,6 +18,7 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { createCodexJournalTranslator } from './codex-structured-journal-translation' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const SWEEP_MS = 5 const RETRY_GAP_MS = 10 * 60_000 @@ -75,6 +76,7 @@ beforeEach(async () => { setOption: async () => undefined } host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter, journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/codex/codex-structured-journal-goal-resume.test.ts b/src/main/codex/codex-structured-journal-goal-resume.test.ts index 6df0ba01575..9edeb004332 100644 --- a/src/main/codex/codex-structured-journal-goal-resume.test.ts +++ b/src/main/codex/codex-structured-journal-goal-resume.test.ts @@ -12,6 +12,7 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexJournalGoals } from './codex-structured-journal-goals' import { MAX_CODEX_GOAL_THREADS } from './codex-structured-journal-limits' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const THREAD = '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc' @@ -34,7 +35,7 @@ function goalFrame(goal: Record = {}): Record } function goalJournal( - options: Parameters[0] = {} + options: Partial[0]> = {} ) { let rowSequence = 0 let publishes = 0 @@ -43,7 +44,10 @@ function goalJournal( let visitedItems = 0 const rows = new Map() const writes: string[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink(options) + const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(), + ...options + }) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a fake journal exposing only the members the goal translator and deferred sink call. const journal = { get epoch() { diff --git a/src/main/codex/codex-structured-journal-goal-revision.test.ts b/src/main/codex/codex-structured-journal-goal-revision.test.ts index 4f827600577..aa455403ea7 100644 --- a/src/main/codex/codex-structured-journal-goal-revision.test.ts +++ b/src/main/codex/codex-structured-journal-goal-revision.test.ts @@ -14,6 +14,7 @@ import { createTrackedJournalOpener } from '../native-chat/agent-session-journal import { readAgentSessionHistory } from '../native-chat/agent-session-wire/agent-session-history-page' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexJournalGoals } from './codex-structured-journal-goals' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const THREAD = '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc' const IDENTITY: AgentSessionJournalIdentity = { @@ -56,7 +57,7 @@ function goalFrame(goal: Record = {}) { /** The host's own sink bound to a real journal. Its publish is what a subscriber * receives: the page after the cursor it had caught up to. */ function journalSink(journal: AgentSessionJournal) { - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const published: AgentSessionHistoryPage[] = [] let subscriberCursor: AgentJournalCursor | null = null deferred.bind({ diff --git a/src/main/codex/codex-structured-journal-subagent-readers.test.ts b/src/main/codex/codex-structured-journal-subagent-readers.test.ts index 21ae85af643..a99b815bff4 100644 --- a/src/main/codex/codex-structured-journal-subagent-readers.test.ts +++ b/src/main/codex/codex-structured-journal-subagent-readers.test.ts @@ -21,6 +21,7 @@ import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/ag import { createCodexJournalTranslator } from './codex-structured-journal-translation' import type { CodexThreadItem } from './codex-thread-item-identity' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const SESSION = 'session-codex-children' const PARENT = 'thread-parent' @@ -52,7 +53,7 @@ async function openJournal(root: string): Promise { async function session() { const root = await mkdtemp(join(tmpdir(), 'orca-codex-children-')) let journal = await openJournal(root) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const activities: (AgentSessionTurnActivity | null)[] = [] deferred.bind({ journal, diff --git a/src/main/codex/codex-structured-journal-translation-settlement.test.ts b/src/main/codex/codex-structured-journal-translation-settlement.test.ts index 344d99e03b4..6f00bdbe2b8 100644 --- a/src/main/codex/codex-structured-journal-translation-settlement.test.ts +++ b/src/main/codex/codex-structured-journal-translation-settlement.test.ts @@ -25,6 +25,7 @@ import { CODEX_USER_INPUT_METHOD } from './codex-structured-prompt-replies' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const SESSION_ID = 'session-1' const THREAD_ID = 'thread-abc' @@ -129,6 +130,7 @@ function deferredTarget( function hardWatermarkDeferred() { return createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(), watermarks: { pauseQueuedBytes: 1, maxQueuedBytes: 1, diff --git a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts index f9714bbe062..07115c4b4e7 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts @@ -26,6 +26,7 @@ import { import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' import type { CodexSession } from './codex-structured-session-state' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const SESSION_ID = 'session-1' const THREAD_ID = 'thread-abc' @@ -223,7 +224,7 @@ describe('codex turn lifecycle rows', () => { now: () => 9_000, stateDirectory: join(root, SESSION_ID) }) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const translator = createCodexJournalTranslator({ sink: deferred.sink, sessionId: SESSION_ID, diff --git a/src/main/codex/codex-structured-journal-translation.test.ts b/src/main/codex/codex-structured-journal-translation.test.ts index 676d9054ed7..523dd1894f8 100644 --- a/src/main/codex/codex-structured-journal-translation.test.ts +++ b/src/main/codex/codex-structured-journal-translation.test.ts @@ -18,6 +18,7 @@ import { CODEX_USER_INPUT_METHOD } from './codex-structured-prompt-replies' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const SESSION_ID = 'session-1' const THREAD_ID = 'thread-abc' @@ -127,6 +128,7 @@ function deferredTarget( function hardWatermarkDeferred() { return createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(), watermarks: { pauseQueuedBytes: 1, maxQueuedBytes: 1, @@ -713,6 +715,7 @@ describe('codex journal translation', () => { const publishes: string[] = [] const readingControl = { pauseReading: vi.fn(), resumeReading: vi.fn() } const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(), watermarks: { pauseQueuedBytes: 1, maxQueuedBytes: 1, diff --git a/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts b/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts index 7011cfa0302..da24f836d1a 100644 --- a/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts +++ b/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts @@ -13,6 +13,7 @@ import { import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const SESSION = 'session-codex-failed-turn' const THREAD = 'thread-abc' @@ -39,7 +40,7 @@ async function session() { stateDirectory: root, now: () => 1_000 }) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind({ journal, fence: 1, publish: () => {} }) cleanups.push(async () => { deferred.close() diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index 069fe4eb048..8ad4b75bd38 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -36,6 +36,7 @@ import { structuredQuestionTranscript } from '../../renderer/src/components/native-chat/structured-agent-question-projection' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -93,6 +94,7 @@ beforeEach(async () => { } store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter, journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts index 44a68b7696b..787b1aa6491 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { NO_LEGACY_JOURNAL_RECORDS, openJournalDatabase } from './journal-database' import { @@ -14,6 +14,7 @@ import { import { AgentSessionJournalError } from './journal-write-guards' import { journalDatabasePath } from './journal-host-database' import { replayJournal } from './journal-open' +import { recordingStructuredAgentSessionLogger } from '../agent-session-wire/structured-agent-session-logger-test-support' let root: string @@ -111,30 +112,39 @@ describe('classifyJournalOpenFailure', () => { describe('journalOpenReadRefusal', () => { it('names the reason, keeps the message the code and the storage text only as the cause', () => { - vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const log = recordingStructuredAgentSessionLogger() const storage = nodeSqliteError(26) - const refusal = journalOpenReadRefusal(storage) + const refusal = journalOpenReadRefusal(storage, log.logger, 'session-1') expect(refusal.message).toBe('agent_session_journal_unreadable') expect(refusal.refusal).toMatchObject({ code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } }) expect(refusal.cause).toBe(storage) - vi.restoreAllMocks() + expect(log.entries).toEqual([ + { + level: 'warn', + message: 'opening the conversation for a read failed', + fields: { scope: 'open-for-read', sessionId: 'session-1', error: storage } + } + ]) }) it('passes a refusal the open already raised through unchanged', () => { const raised = agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) - expect(journalOpenReadRefusal(raised)).toBe(raised) + expect( + journalOpenReadRefusal(raised, recordingStructuredAgentSessionLogger().logger, 's') + ).toBe(raised) }) }) describe('createJournalOpenReadRefusals', () => { it('logs a session once per failure until it opens, and each session on its own', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const refusals = createJournalOpenReadRefusals() + const log = recordingStructuredAgentSessionLogger() + const logged = () => log.entries.map((entry) => entry.fields.sessionId) + const refusals = createJournalOpenReadRefusals(log.logger) const denied = systemError('EACCES', -13) const corrupt = nodeSqliteError(26) @@ -143,17 +153,21 @@ describe('createJournalOpenReadRefusals', () => { expect(refusal.refusal).toMatchObject({ details: { reason: 'journalUnavailable' } }) expect(refusal.cause).toBe(denied) } - expect(warn).toHaveBeenCalledTimes(1) + expect(logged()).toEqual(['session-1']) refusals.refusal('session-2', denied) - expect(warn).toHaveBeenCalledTimes(2) + expect(logged()).toEqual(['session-1', 'session-2']) expect(refusals.refusal('session-1', corrupt).refusal).toMatchObject({ details: { reason: 'journalCorrupt' } }) - expect(warn).toHaveBeenCalledTimes(3) + expect(logged()).toEqual(['session-1', 'session-2', 'session-1']) refusals.forget('session-1') refusals.refusal('session-1', corrupt) - expect(warn).toHaveBeenCalledTimes(4) - vi.restoreAllMocks() + expect(log.scopes()).toEqual([ + 'open-for-read', + 'open-for-read', + 'open-for-read', + 'open-for-read' + ]) }) }) @@ -176,13 +190,12 @@ describe('a journal a newer Orca wrote', () => { }) it('refuses a read with the same reason', () => { - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - expect(journalOpenReadRefusal(readOnly()).refusal).toMatchObject({ + const { logger } = recordingStructuredAgentSessionLogger() + expect(journalOpenReadRefusal(readOnly(), logger, 'session-1').refusal).toMatchObject({ details: { reason: 'journalWrittenByNewerOrca' } }) - expect(createJournalOpenReadRefusals().refusal('session-1', readOnly()).refusal).toMatchObject({ - details: { reason: 'journalWrittenByNewerOrca' } - }) - vi.restoreAllMocks() + expect( + createJournalOpenReadRefusals(logger).refusal('session-1', readOnly()).refusal + ).toMatchObject({ details: { reason: 'journalWrittenByNewerOrca' } }) }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.ts index 5e014c279cb..d154e1b29e6 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.ts @@ -13,6 +13,7 @@ import { } from '../../../shared/agent-session-wire-refusals' import { isSqliteCorruption } from '../../sqlite/sqlite-read-failure' import { AgentSessionJournalError } from './journal-write-guards' +import type { StructuredAgentSessionLogger } from '../agent-session-wire/structured-agent-session-logger' type JournalRefusalReason = AgentSessionRefusalReason<'agent_session_journal_unreadable'> @@ -107,11 +108,15 @@ export function journalOpenRefusalError(error: unknown): AgentSessionRefusalErro * (a path, "file is not a database") goes to the log only; the reader gets the classified refusal, * whose message stays the bare code. */ -export function journalOpenReadRefusal(error: unknown): AgentSessionRefusalError { +export function journalOpenReadRefusal( + error: unknown, + logger: StructuredAgentSessionLogger, + sessionId: string +): AgentSessionRefusalError { if (isAgentSessionRefusalError(error)) { return error } - return unreadableRefusal(error, journalRefusalReason(error), true) + return unreadableRefusal(error, journalRefusalReason(error), { logger, sessionId }) } const MAX_LOGGED_SESSIONS = 256 @@ -120,7 +125,7 @@ const MAX_LOGGED_SESSIONS = 256 * The read door's refusals for one host. A reader reconnects on a timer while an open can clear, * so a session's failure is logged once until that session opens or the failure changes. */ -export function createJournalOpenReadRefusals() { +export function createJournalOpenReadRefusals(logger: StructuredAgentSessionLogger) { const logged = new Map() return { refusal: (sessionId: string, error: unknown): AgentSessionRefusalError => { @@ -134,7 +139,7 @@ export function createJournalOpenReadRefusals() { if (!repeat && (logged.has(sessionId) || logged.size < MAX_LOGGED_SESSIONS)) { logged.set(sessionId, failure) } - return unreadableRefusal(error, reason, !repeat) + return unreadableRefusal(error, reason, repeat ? null : { logger, sessionId }) }, /** The session opened or closed: its next failure is news. */ forget: (sessionId: string): void => { @@ -146,11 +151,13 @@ export function createJournalOpenReadRefusals() { function unreadableRefusal( error: unknown, reason: JournalRefusalReason, - log: boolean + log: { logger: StructuredAgentSessionLogger; sessionId: string } | null ): AgentSessionRefusalError { - if (log) { - console.warn('[agent-session] opening the conversation for a read failed:', error) - } + log?.logger.warn('opening the conversation for a read failed', { + scope: 'open-for-read', + sessionId: log.sessionId, + error + }) const code = 'agent_session_journal_unreadable' return new AgentSessionRefusalError(refuse(code, { reason }, code), { cause: error }) } diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts index c9737bf2007..9ec154d0e5d 100644 --- a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts @@ -28,6 +28,7 @@ import { import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths' import { importPerSessionJournal } from './journal-per-session-import' import { readJournalSessionEpoch, type JournalStoredRow } from './journal-row-table' +import { createStructuredAgentSessionLogger } from '../agent-session-wire/structured-agent-session-logger' vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() @@ -221,6 +222,7 @@ describe('importing a per-chat journal', () => { const journal = await openChat() const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([[IDENTITY.sessionId, { journal, child: null }]]), now: () => clock, enqueue: (operation) => operation() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index a80c147607d..b3f91aea700 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -45,6 +45,7 @@ import { import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -76,6 +77,7 @@ const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spa async function startHost(): Promise { host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index a0d2f0fdb7d..c98854292d5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -20,6 +20,7 @@ import { openTestAttachConversation } from './structured-agent-session-attach-te import { performAttach } from './structured-agent-session-attach-flow' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const NOW = 1_800_000_000_000 const SESSION = 'legacy-session' @@ -151,6 +152,7 @@ describe('structured session acquisition options', () => { let firstJournal: AgentSessionJournal | undefined const first = await performAttach({ + logger: createStructuredAgentSessionLogger(), store: initialStore, adapter: withHistory('created'), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), @@ -188,6 +190,7 @@ describe('structured session acquisition options', () => { const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const second = await performAttach({ + logger: createStructuredAgentSessionLogger(), store, adapter: withHistory('resumed'), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), @@ -219,6 +222,7 @@ describe('structured session acquisition options', () => { const recordPhase = vi.fn() const created = await performAttach({ + logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), @@ -248,6 +252,7 @@ describe('structured session acquisition options', () => { const sessionAdapter = adapter({ origin: 'created' }) const attempt = async (options: Readonly>, spawnToken: string) => performAttach({ + logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), @@ -278,6 +283,7 @@ describe('structured session acquisition options', () => { const store = await openTestAgentSessionRecordStore(root) const created = await performAttach({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter({ origin: 'created' }), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), @@ -308,6 +314,7 @@ describe('structured session acquisition options', () => { }) const releasedFence = resumedStore.getRecord(SESSION)?.lease.runtimeFence ?? 0 const resumed = await performAttach({ + logger: createStructuredAgentSessionLogger(), store: resumedStore, adapter: adapter({ origin: 'resumed', @@ -350,6 +357,7 @@ describe('structured session acquisition options', () => { }) const created = await performAttach({ + logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), @@ -386,6 +394,7 @@ describe('structured session acquisition options', () => { await expect( performAttach({ + logger: createStructuredAgentSessionLogger(), store, adapter: failingAdapter, openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), @@ -479,6 +488,7 @@ describe('structured session acquisition options', () => { fence: number | null ) => performAttach({ + logger: createStructuredAgentSessionLogger(), store: target, adapter: failingAdapter, openConversation: openTestAttachConversation( @@ -580,6 +590,7 @@ describe('the tab a create reserves', () => { function attachWith(store: AgentSessionRecordStore, surfaceTabId?: string) { return performAttach({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter({ origin: 'created' }), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 6bb43fb9d31..76a276abd23 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -19,6 +19,7 @@ import { AgentSessionJournal } from '../agent-session-journal/journal-store' import * as legacyImport from '../agent-session-journal/journal-legacy-import' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const NOW = 1_800_000_000_000 const SESSION = 'codex_adopting_session' @@ -122,6 +123,7 @@ async function attach( ) { store ??= await openTestAgentSessionRecordStore(root!) return performAttach({ + logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), @@ -233,6 +235,7 @@ describe('adopting a provider conversation on create', () => { await writeCodexRollout(transcriptPath, 'valid source') store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index a4c93fa00f8..6fbcca0dce2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -79,7 +79,11 @@ export function ensureStructuredAgentSessionAgentForOperation( ): Promise { return ensureStructuredAgentSessionAgent(context, sessionId).catch((error: unknown) => { // The error is Orca's own and goes to the log; the refusal says only that the start failed. - console.warn('[agent-session] starting the agent for an operation failed:', error) + context.deps.logger.warn('starting the agent for an operation failed', { + scope: 'operation-agent-start', + sessionId, + error + }) return { ok: false, refusal: refuseUnclassified( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index bda7cc9330f..e2adb2953cb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -128,6 +129,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts index 063cf84ebd7..140a9920115 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts @@ -18,6 +18,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { HOST_TEST_LOCATION, HOST_TEST_NOW, @@ -87,6 +88,7 @@ async function openHost(catalog = catalogFor(workspace)): Promise { adapter: adapter(catalog), journalDatabase: openTestJournalHostDatabase(directory), claimKeyId: 'key', + logger: recordingStructuredAgentSessionLogger().logger, now: () => clock, probeOwner: async () => ({ outcome: 'pid-absent' }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index c469035c468..d1b6cd28087 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -44,10 +44,12 @@ import { } from '../../observability/agent-session-instrumentation' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type AttachFlowInput = { store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter + logger: StructuredAgentSessionLogger authority: AgentSessionAttachAuthority callerKey: string params: AgentSessionAttachParams @@ -206,7 +208,11 @@ export async function performAttach( const thrown = failed ? error : preSpawnFailureInWords(error, wording) if (failed || thrown !== error) { // The answer carries only its sentence, so what failed is kept here. - console.warn('[agent-session] provider start failed:', error) + input.logger.warn('starting the provider failed', { + scope: 'provider-start', + sessionId, + error + }) } return ( failed ?? { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 9cfdf6a9b87..d8f89065de4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -135,6 +135,7 @@ async function runAttach( const attached = await performAttach({ store: context.deps.store, adapter: context.deps.adapter, + logger: context.deps.logger, eventSink: attemptSink.sink, // The superseded child's writes settle into its own journal before a new child starts. onAcquiring: async () => { @@ -201,7 +202,7 @@ async function runAttach( } } await recoverStructuredRewind( - context.deps.store, + context.deps, sessionId, attached.journal, fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts index aa0ae9e29e8..7a9f758858a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts @@ -3,6 +3,7 @@ import type { JournalHostDatabase } from '../agent-session-journal/journal-host- import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { openStructuredAgentSessionConversationJournal } from './structured-agent-session-conversation-open' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' /** For a test that attaches without a host: the conversation's journal, through the one open a * host would take, so the attach under test adopts it the way it adopts the host's. */ @@ -12,8 +13,12 @@ export function openTestAttachConversation( ): (record: AgentSessionRecord) => Promise { return async (record) => ( - await openStructuredAgentSessionConversationJournal({ journalDatabase, adapter }, record, { - acquisition: true - }) + await openStructuredAgentSessionConversationJournal( + { journalDatabase, adapter, logger: recordingStructuredAgentSessionLogger().logger }, + record, + { + acquisition: true + } + ) ).session.journal } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts index 11505b6c204..92c52f88a68 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts @@ -10,6 +10,7 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import type { AgentSessionSubscribers } from './structured-agent-session-subscribers' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const child: AgentChildWorkView = { id: 'child-1', @@ -31,7 +32,7 @@ function channelOver( ) { const sessions = new StructuredAgentSessionConversations({ deliver: () => {}, - onDeliveryError: () => {}, + logger: recordingStructuredAgentSessionLogger().logger, now: () => 1 }) const sent = vi.fn() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index af618040fa3..70707019295 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -108,7 +108,12 @@ export function mutateWithChatStop( { sessionId, adapter: context.deps.adapter }, windDown, () => context.stopAgent(sessionId), - (error) => context.deps.onEventSinkError?.({ sessionId, error }) + (error) => + context.deps.logger.warn("ending a stopped chat's provider session failed", { + scope: 'chat-stop', + sessionId, + error + }) ) } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts index 041b6580ea2..bbdc2b50b2f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts @@ -28,6 +28,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -70,6 +71,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, // The production router is what declares create support; the bare adapter only knows locations. adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts index 1ba4f13e4ea..747319c3c66 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts @@ -27,6 +27,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -66,6 +67,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts index 6398d96ab0a..1bb3d36a5c4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts @@ -16,6 +16,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-claude' } const CLAUDE_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' @@ -83,6 +84,7 @@ beforeEach(async () => { optionWritable = Promise.resolve() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index 879f83b8a6c..2531450fd22 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -29,6 +29,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } // As Claude Code 2.1.280 advertises them on a turn's system/init frame. @@ -82,6 +83,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index 778bd3cc2fa..f09fe672b5e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -17,6 +17,7 @@ import type { AgentSessionAttachParams } from './structured-agent-session-attach import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const NOW = 1_788_727_031_330 const roots: string[] = [] @@ -121,7 +122,8 @@ describe('Claude root-exit stop', () => { adapter, // The one database the store and the journal share, as the runtime installs them. journalDatabase: openTestJournalHostDatabase(stateDirectory), - claimKeyId: 'key-1' + claimKeyId: 'key-1', + logger: createStructuredAgentSessionLogger() } const runtimeState = new StructuredAgentSessionHostRuntimeState(deps) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index ad314376b1a..845647f3c0a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -32,6 +32,7 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -52,7 +53,7 @@ let store: AgentSessionRecordStore let queued: string[] let claude: ReturnType let events: ClaudeStructuredSessionEvent[] -let sinkErrors: unknown[] +let logs: ReturnType // The host's status row and child records, as the app's hook server holds them. let server: AgentHookServer let statusSubject: AgentStatusStructuredSessionSubject | undefined @@ -62,7 +63,7 @@ beforeEach(async () => { resetHostTestOperationIds() queued = [] events = [] - sinkErrors = [] + logs = recordingStructuredAgentSessionLogger() server = new AgentHookServer() statusSubject = undefined claude = fakeClaude({ @@ -107,7 +108,7 @@ beforeEach(async () => { journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', - onEventSinkError: ({ error }) => sinkErrors.push(error), + logger: logs.logger, statusSink: { publish: (summary, subject) => { statusSubject = subject @@ -601,10 +602,15 @@ it('still answers the Stop, with its row, when the child cannot be proven gone; await laneDrained() expect(await statusTexts()).toEqual(['Cancellation requested.']) - expect(sinkErrors).toEqual([ + expect(logs.entries).toEqual([ expect.objectContaining({ - name: 'StructuredAgentSessionEvictionError', - step: 'stop-provider-child' + fields: expect.objectContaining({ + scope: 'chat-stop', + error: expect.objectContaining({ + name: 'StructuredAgentSessionEvictionError', + step: 'stop-provider-child' + }) + }) }) ]) connection.close = close @@ -660,7 +666,7 @@ it('keeps a second Stop pressed while the first ends the child quiet', async () expect(connection.closed).toBe(true) expect(connection.calls.filter((call) => call.subtype === 'interrupt')).toHaveLength(1) expect(await statusTexts()).toEqual(['Cancellation requested.']) - expect(sinkErrors).toEqual([]) + expect(logs.entries).toEqual([]) }) const BRANCH_QUESTION = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index 83d80437de3..4b51f9cfe77 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -31,6 +31,7 @@ import { childEndCauseOfEndedEvent, turnVerdictForChildEnd } from './structured-agent-session-stale-turn-verdict' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CUT_TURN = { provider: 'codex' as const, threadId: THREAD, turnId: 'cut-turn', ordinal: 1 } @@ -51,6 +52,7 @@ beforeEach(() => { exitObservedFirst = false closeCalls = 0 host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store: state.store, adapter: { ...adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index 8833ac4ee31..9103393c26c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -24,6 +24,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -51,6 +52,7 @@ beforeEach(async () => { } const store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapterFor(codex), { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index 2dd2b5229aa..499c5f3ff48 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -317,8 +317,14 @@ describe('the wind-down retry with a message queued (P2-31)', () => { stopAgent, stopStartingAgent: stopAgent, closeConversation: vi.fn(async () => false), - onError: (_id, error) => { - throw error + // A failed step fails the test. + logger: { + warn: (_message, fields) => { + throw fields.error + }, + error: (_message, fields) => { + throw fields.error + } } }) await sweep.tick() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 1980616f176..491021dc1c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -24,6 +24,7 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-sessi import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { adapterSupportsRecord } from './structured-agent-session-provider-support' +import { deferredStructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionConversationLifetime = ReturnType< typeof createStructuredAgentSessionConversationLifetime @@ -45,7 +46,9 @@ export function createStructuredAgentSessionConversationLifetime(host: { let disposed = false const { sessions, serialize } = host const deps = () => host.context().deps - const readRefusals = createJournalOpenReadRefusals() + const readRefusals = createJournalOpenReadRefusals( + deferredStructuredAgentSessionLogger(() => deps().logger) + ) // The owed copy fails as an open does: the reader gets the classified refusal, never its text. const whenImported = (sessionId: string, session: StructuredAgentSessionHostSession) => session.journal.whenImported().catch((error: unknown) => { @@ -88,7 +91,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { cause: 'host-stop' }), closeConversation, - onError: (sessionId, error) => deps().onEventSinkError?.({ sessionId, error }), + logger: deps().logger, ...deps().idleSweep }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index ec938f17f13..73285ee1de4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -38,7 +38,7 @@ export type StructuredAgentSessionConversationOpenDeps = { store: Pick adapter: Pick journalDatabase: JournalHostDatabase - onEventSinkError?: StructuredAgentSessionHostDeps['onEventSinkError'] + logger: StructuredAgentSessionHostDeps['logger'] } /** An acquisition's own open: its reserve cleared the record's death evidence, so it settles @@ -104,7 +104,11 @@ export async function openStructuredAgentSessionConversationJournal( // one is only doubt, which provider history decides under a won lease. await opened.journal.markPendingSubmissionsUnknown(fence) } catch (error) { - deps.onEventSinkError?.({ sessionId, error }) + deps.logger.warn('marking pending sends unknown on open failed', { + scope: 'open-pending-unknown', + sessionId, + error + }) } // No child in this process writes to a journal nobody had open, so whatever it shows running // belongs to a generation that is gone, whatever the lease still claims. Settled before any @@ -135,7 +139,7 @@ export async function resettleOpenStructuredAgentSessionConversation( } async function settleGoneGeneration( - deps: Pick, + deps: Pick, record: AgentSessionRecord, journal: AgentSessionJournal ): Promise { @@ -150,7 +154,11 @@ async function settleGoneGeneration( }) } catch (error) { // Best effort: the next open or acquire re-derives it. - deps.onEventSinkError?.({ sessionId: record.sessionId, error }) + deps.logger.warn("settling a gone agent's work on open failed", { + scope: 'open-dead-generation', + sessionId: record.sessionId, + error + }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index 55f58d06a59..0f7c471ed0a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -55,6 +56,7 @@ beforeEach(async () => { stopEndsSession = false store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire: async ({ fence, spawnToken, events: sink }) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts index 3557594b751..ec4c76e26ba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts @@ -9,6 +9,7 @@ import { createTrackedJournalOpener } from '../agent-session-journal/journal-hos import { StructuredAgentSessionConversations } from './structured-agent-session-conversations' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { hostTestAttachParams } from './structured-agent-session-host-test-data' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -55,7 +56,7 @@ describe('a conversation delivers what its journal commits', () => { const deliver = vi.fn() const conversations = new StructuredAgentSessionConversations({ deliver, - onDeliveryError: vi.fn(), + logger: recordingStructuredAgentSessionLogger().logger, now: () => 0 }) const journal = await openJournal('a') @@ -74,7 +75,7 @@ describe('a conversation delivers what its journal commits', () => { const sessions: Map = new StructuredAgentSessionConversations({ deliver, - onDeliveryError: vi.fn(), + logger: recordingStructuredAgentSessionLogger().logger, now: () => 0 }) const journal = await openJournal('a') @@ -89,7 +90,7 @@ describe('a conversation delivers what its journal commits', () => { const deliver = vi.fn() const conversations = new StructuredAgentSessionConversations({ deliver, - onDeliveryError: vi.fn(), + logger: recordingStructuredAgentSessionLogger().logger, now: () => 0 }) const journal = await openJournal('a') @@ -104,7 +105,7 @@ describe('a conversation delivers what its journal commits', () => { const deliver = vi.fn() const conversations = new StructuredAgentSessionConversations({ deliver, - onDeliveryError: vi.fn(), + logger: recordingStructuredAgentSessionLogger().logger, now: () => 0 }) const replaced = await openJournal('a') @@ -123,7 +124,7 @@ describe('a conversation delivers what its journal commits', () => { const deliver = vi.fn() const conversations = new StructuredAgentSessionConversations({ deliver, - onDeliveryError: vi.fn(), + logger: recordingStructuredAgentSessionLogger().logger, now: () => 0 }) const journal = await openJournal('a') @@ -137,12 +138,12 @@ describe('a conversation delivers what its journal commits', () => { it('reports a reader failure without failing the durable write', async () => { const failure = new Error('reader failed') - const onDeliveryError = vi.fn() + const log = recordingStructuredAgentSessionLogger() const conversations = new StructuredAgentSessionConversations({ deliver: () => { throw failure }, - onDeliveryError, + logger: log.logger, now: () => 0 }) const journal = await openJournal('a') @@ -152,7 +153,9 @@ describe('a conversation delivers what its journal commits', () => { itemId: expect.any(String) }) - expect(onDeliveryError).toHaveBeenCalledExactlyOnceWith('session-1', failure) + expect(log.entries.map((entry) => entry.fields)).toEqual([ + { scope: 'journal-delivery', sessionId: 'session-1', error: failure } + ]) expect(journal.snapshot().items.map((item) => item.body)).toContainEqual({ kind: 'status', text: 'durable' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts index 70fe538ce20..b634b74c212 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts @@ -1,5 +1,6 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' /** * The host's open conversations. A journal handle becomes a conversation's when it is set here, @@ -22,7 +23,7 @@ export class StructuredAgentSessionConversations extends Map< constructor( private readonly delivery: { deliver: (sessionId: string, journal: AgentSessionJournal) => void - onDeliveryError: (sessionId: string, error: unknown) => void + logger: StructuredAgentSessionLogger /** A conversation became held: state that waited on it (queued drafts) re-derives. */ onOpened?: (sessionId: string) => void now: () => number @@ -47,7 +48,11 @@ export class StructuredAgentSessionConversations extends Map< try { this.delivery.deliver(sessionId, journal) } catch (error) { - this.delivery.onDeliveryError(sessionId, error) + this.delivery.logger.warn('delivering a journal commit failed', { + scope: 'journal-delivery', + sessionId, + error + }) } }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts index 22295ea6e86..059567386a6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts @@ -25,6 +25,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const CHILD_PID = 4321 @@ -92,6 +93,7 @@ function host( overrides: Partial = {} ): StructuredAgentSessionHost { return new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter, journalDatabase: openTestJournalHostDatabase(generationRoot(generation)), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index 9d396b58ffa..7e237615089 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -45,6 +45,8 @@ import { HOST_TEST_LOCATION as LOCATION, HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const PROVIDER_SESSION = 'provider-session-alpha-1' /** The tool call's row: the last thing the provider wrote before the crash. */ @@ -155,6 +157,7 @@ async function seedClaudeToolTurn(): Promise { function openHost(overrides: Partial): void { host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire: vi.fn(), @@ -440,11 +443,11 @@ describe('a turn a read reached before the reconcile proved its owner dead', () it('stays unverifiable when the revision cannot be written, and a later open revises it', async () => { let now = RELAUNCHED_AT - const onEventSinkError = vi.fn() + const log = recordingStructuredAgentSessionLogger() openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }), now: () => now, - onEventSinkError + logger: log.logger }) await host.history({ sessionId: SESSION, direction: 'tail' }) const { journal } = host.collaboratorsForTests().sessions.get(SESSION)! @@ -453,7 +456,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () await host.reconcileRestartLeases() await drainSession() - expect(onEventSinkError).toHaveBeenCalledOnce() + expect(log.scopes()).toEqual(['open-dead-generation']) expect(await settledTurn()).toEqual(UNVERIFIABLE_TURN) // The proof is durable on the record, so the next open converges. now += STRUCTURED_AGENT_SESSION_IDLE_MS + 1 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 6677a50b447..b39790e6b2a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -100,7 +100,7 @@ describe('dead structured-session generation settlement', () => { verdict: { state: 'unverifiable' }, showUnexpectedExitOutcome: false }) - ).resolves.toBe(true) + ).resolves.toEqual({ ok: true }) const snapshot = journal.snapshot() expect(snapshot.submissions).toEqual([ @@ -135,9 +135,9 @@ describe('dead structured-session generation settlement', () => { showUnexpectedExitOutcome: true } - await expect(settleStructuredAgentSessionDeadGeneration(input)).resolves.toBe(true) + await expect(settleStructuredAgentSessionDeadGeneration(input)).resolves.toEqual({ ok: true }) const settledCursor = journal.cursor() - await expect(settleStructuredAgentSessionDeadGeneration(input)).resolves.toBe(true) + await expect(settleStructuredAgentSessionDeadGeneration(input)).resolves.toEqual({ ok: true }) expect(journal.cursor()).toEqual(settledCursor) expect( @@ -166,7 +166,7 @@ describe('dead structured-session generation settlement', () => { detail: providerDiagnostic('stack frame '.repeat(4_000), 'log') }) }) - ).resolves.toBe(true) + ).resolves.toEqual({ ok: true }) const statuses = journal .snapshot() @@ -258,7 +258,7 @@ describe('dead structured-session generation settlement', () => { verdict: { state: 'interrupted', completedAt: 1_000 }, showUnexpectedExitOutcome: false }) - ).resolves.toBe(true) + ).resolves.toEqual({ ok: true }) }) it('settles a live unknown submission even when no unfinished item remains', async () => { @@ -285,7 +285,7 @@ describe('dead structured-session generation settlement', () => { verdict: { state: 'interrupted', completedAt: 1_000 }, showUnexpectedExitOutcome: false }) - ).resolves.toBe(true) + ).resolves.toEqual({ ok: true }) expect(journal.submissions()).toEqual([ expect.objectContaining({ @@ -463,7 +463,7 @@ describe('whether a dead generation interrupted anything', () => { 1_000 ) }) - ).resolves.toBe(true) + ).resolves.toEqual({ ok: true }) const snapshot = journal.snapshot() expect(snapshot.items.some((item) => item.body.kind === 'status')).toBe(false) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 72caac40844..8ec0768cc52 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -102,6 +102,11 @@ export function unfinishedStructuredAgentSessionWorkWasInterrupted( return outcomeItems.some((item) => !isCleanlySettled(currentItems.get(item.itemId))) } +/** Whether the settlement was written, and what stopped it when it was not. */ +export type StructuredAgentSessionDeadGenerationSettlement = + | { ok: true } + | { ok: false; error: unknown } + export async function settleStructuredAgentSessionDeadGeneration(input: { journal: DeadGenerationJournal sessionId: string @@ -117,13 +122,12 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { /** The provider never finished starting: the start that failed, keyed by the child's * generation. Its row is the one the delivery loop writes for the same start. */ exitedDuringStartup?: { generation: string | null } - onError?: (sessionId: string, error: unknown) => void -}): Promise { +}): Promise { try { const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal) const showUnexpectedExitOutcome = input.showUnexpectedExitOutcome ?? hasUnfinishedWork if (!showUnexpectedExitOutcome && !hasUnfinishedWork) { - return true + return { ok: true } } // A queued message is the delivery loop's to settle: it was never handed to this child. A // child that never proved its start accepted nothing either — input is written only after it @@ -189,10 +193,10 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { mutations: chunk.mutations }) } - return true + return { ok: true } } catch (error) { - input.onError?.(input.sessionId, error) - return false + // Returned rather than logged: each caller logs it under its own scope. + return { ok: false, error } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index ad149dbf947..3727040e3c0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -40,6 +40,7 @@ import { import { failedProviderChildStart } from './structured-agent-session-provider-child' import { handOverSubmission } from './structured-agent-session-turns' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionDeliveryLoopDeps = { sessions: ReadonlyMap @@ -62,7 +63,7 @@ export type StructuredAgentSessionDeliveryLoopDeps = { ) => Promise /** Who the chat's failure sentences name. */ failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext - onError: (sessionId: string, error: unknown) => void + logger: StructuredAgentSessionLogger record: (sessionId: string) => AgentSessionRecord | null readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined flushStreamedEvents: (sessionId: string) => Promise @@ -139,14 +140,22 @@ export class StructuredAgentSessionDeliveryLoop { } } catch (error) { // The error is Orca's own and goes to the log; the chat says only that Orca failed. - this.deps.onError(sessionId, error) + this.deps.logger.warn('delivering a queued message failed', { + scope: 'delivery-loop', + sessionId, + error + }) const cause = { hostFault: true } as const await this.deps .serialize(sessionId, () => this.fail(sessionId, { startKey: null, cause })) .catch((failure: unknown) => { // Rows left queued are rejected by the next open, or by the next loop an accept wakes. this.running.delete(sessionId) - this.deps.onError(sessionId, failure) + this.deps.logger.warn('recording a failed delivery failed', { + scope: 'delivery-loop-fail', + sessionId, + error: failure + }) }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 56a0acf853d..2b9340859f4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -22,10 +22,13 @@ export class StructuredAgentSessionEventRecovery { publishStatus?: (sessionId: string) => void serialize: (sessionId: string, task: () => Promise) => Promise now: () => number - onBarrierError: (sessionId: string, error: unknown) => void } ) {} + private get exitContext() { + return { ...this.context, logger: this.context.deps.logger } + } + recoverAfterSinkFailure(sessionId: string, error: unknown): void { if (this.sinkFailures.has(sessionId)) { return @@ -56,7 +59,16 @@ export class StructuredAgentSessionEventRecovery { } as const }) .then((event) => (event ? this.handle(event) : undefined)) - .catch((recoveryError) => this.context.onBarrierError(sessionId, recoveryError)) + .catch((error: unknown) => + this.context.deps.logger.warn( + 'stopping a provider after its journal failed did not finish', + { + scope: 'sink-failure-recovery', + sessionId, + error + } + ) + ) .finally(() => this.sinkFailures.delete(sessionId)) } @@ -66,6 +78,6 @@ export class StructuredAgentSessionEventRecovery { if (event.type === 'started') { return settleStructuredAgentSessionProviderStarted(this.context, event) } - await settleUnexpectedStructuredAgentSessionExit(this.context, event) + await settleUnexpectedStructuredAgentSessionExit(this.exitContext, event) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index 2d0766a4bce..508893d67f4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -45,7 +45,8 @@ export class StructuredAgentSessionSinkQueue { constructor( private readonly deps: { watermarks: StructuredAgentSessionSinkWatermarks - onError?: (error: unknown) => void + /** The queue just failed for good; it accepts and runs nothing more. */ + onFailed?: (error: unknown) => void readingControl?: StructuredAgentSessionReadingControl onBackpressureChange?: ( backpressured: boolean, @@ -212,7 +213,7 @@ export class StructuredAgentSessionSinkQueue { private fail = (error: unknown): void => { if (this.failure === null) { this.failure = { error } - this.deps.onError?.(error) + this.deps.onFailed?.(error) } this.queue.length = 0 this.queuedBytes = 0 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index 46dc8fbf7e4..2993ce35a4e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -15,6 +15,8 @@ import { type StructuredAgentSessionEventTarget } from './structured-agent-session-event-sink' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { testEventSinkLogging } from './structured-agent-session-logger-test-support' const BODY: AgentJournalItemBody = { kind: 'message', @@ -91,7 +93,7 @@ function target( describe('deferred structured agent-session event sink', () => { it('buffers writes made before the journal exists and drains them in arrival order', async () => { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) @@ -110,7 +112,7 @@ describe('deferred structured agent-session event sink', () => { it('writes at the fence bound at submission time, so a rebind cannot backdate a write', async () => { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind(target(1, log)) deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) @@ -127,7 +129,7 @@ describe('deferred structured agent-session event sink', () => { it('buffers replacement-acquisition events while unbound', async () => { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind(target(1, log)) deferred.unbind() @@ -141,7 +143,7 @@ describe('deferred structured agent-session event sink', () => { it('resolves a lifecycle transition after journal bind and skips an existing state', async () => { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) expect( deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => identity(1), { @@ -166,7 +168,7 @@ describe('deferred structured agent-session event sink', () => { it('drops buffered and later writes once closed, and refuses to rebind', async () => { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) deferred.close() @@ -182,7 +184,8 @@ describe('deferred structured agent-session event sink', () => { const errors: unknown[] = [] const readingControl = { pauseReading: vi.fn(), resumeReading: vi.fn() } const deferred = createDeferredStructuredAgentSessionEventSink({ - onError: (error) => errors.push(error), + ...testEventSinkLogging(), + onFailed: (error) => errors.push(error), readingControl }) deferred.bind(target(4, log, 0)) @@ -206,7 +209,11 @@ describe('deferred structured agent-session event sink', () => { }) it('replaces a failed cached sink before recovery drain', async () => { - const runtime = new StructuredAgentSessionHostRuntimeState({ store: {} } as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cached-sink path reads only the logger, on the failed drain. + const runtime = new StructuredAgentSessionHostRuntimeState({ + store: {}, + logger: createStructuredAgentSessionLogger() + } as never) const failed = runtime.eventSinkFor('session-1') failed.bind(target(1, [], 0)) failed.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) @@ -226,6 +233,7 @@ describe('deferred structured agent-session event sink', () => { const changes: boolean[] = [] const readingControl = { pauseReading: vi.fn(), resumeReading: vi.fn() } const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(), watermarks: { maxQueuedBytes: 1_000_000, lowQueuedBytes: 0, @@ -262,6 +270,7 @@ describe('deferred structured agent-session event sink', () => { it('admits a resolved append and publication as one bounded operation', async () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(), watermarks: { pauseQueuedOperations: 1, maxQueuedOperations: 2, @@ -299,6 +308,7 @@ describe('deferred structured agent-session event sink', () => { const changes: boolean[] = [] const readingControl = { pauseReading: vi.fn(), resumeReading: vi.fn() } const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(), watermarks: { pauseQueuedBytes: 1, maxQueuedBytes: 1_000_000, @@ -329,7 +339,8 @@ describe('deferred structured agent-session event sink', () => { const log: Recorded[] = [] const errors: unknown[] = [] const deferred = createDeferredStructuredAgentSessionEventSink({ - onError: (error) => errors.push(error), + ...testEventSinkLogging(), + onFailed: (error) => errors.push(error), watermarks: { pauseQueuedBytes: 1, maxQueuedBytes: 1, @@ -364,6 +375,7 @@ describe('deferred structured agent-session event sink', () => { const firstControl = { pauseReading: vi.fn(), resumeReading: vi.fn() } const secondControl = { pauseReading: vi.fn(), resumeReading: vi.fn() } const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(), watermarks: { pauseQueuedBytes: 1, maxQueuedBytes: 1_000_000, @@ -395,7 +407,7 @@ describe('deferred structured agent-session event sink', () => { it('replaces a queued same-item checkpoint before it runs', async () => { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const options = { coalescingKey: 'checkpoint:item-1', turnScope: AGENT_JOURNAL_THREAD_SCOPE } deferred.sink.appendItem(identity(0), BODY, options) @@ -411,7 +423,7 @@ describe('deferred structured agent-session event sink', () => { // The journal applies a settlement id once and skips any later batch with it, // so the queue must not let a later batch replace one it has not run yet. const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const batch = (ordinal: number) => [ { kind: 'item' as const, @@ -440,7 +452,7 @@ describe('deferred structured agent-session event sink', () => { it('keeps a replacement checkpoint after distinct intervening operations', async () => { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const options = { coalescingKey: 'checkpoint:item-1', turnScope: AGENT_JOURNAL_THREAD_SCOPE } deferred.sink.appendItem(identity(0), BODY, options) @@ -457,7 +469,7 @@ describe('deferred structured agent-session event sink', () => { it('coalesces provider activity as a publication without a journal write', async () => { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.sink.setActivity?.({ turnId: 'turn-1', text: 'Thinking' }) deferred.sink.setActivity?.({ turnId: 'turn-1', text: 'Checking the result' }) @@ -486,7 +498,7 @@ describe('producer linkage reaches the journal through every append path', () => it('forwards the whole bundle on the plain and try append paths', async () => { for (const append of ['appendItem', 'tryAppendItem'] as const) { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind(target(5, log)) deferred.sink[append]?.(identity(1), BODY, { ...LINKAGE, @@ -510,7 +522,7 @@ describe('producer linkage reaches the journal through every append path', () => 'tryAppendLifecycleTransition' ] as const) { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind(target(5, log)) deferred.sink[append]?.(identity(1), BODY, () => identity(1), { ...LINKAGE, @@ -530,7 +542,7 @@ describe('producer linkage reaches the journal through every append path', () => // would stamp whoever opened the batch onto all of them. Both callers are // single-producer today; a mixed batch would have to stamp per mutation. const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind(target(5, log)) deferred.sink.appendLifecycleBatch?.( 'settle-1', @@ -547,7 +559,7 @@ describe('producer linkage reaches the journal through every append path', () => it("writes no linkage keys at all for the session's own agent", async () => { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind(target(5, log)) deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await deferred.drained() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 121fb865db4..18003e72369 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -12,6 +12,7 @@ import { estimateStructuredAgentSessionItemBytes } from './structured-agent-sess import { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' import { structuredAgentSessionJournalAppendOptions } from './structured-agent-session-journal-append-options' import { createStructuredAgentSessionResolvedAppend } from './structured-agent-session-resolved-append' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionSinkAdmission = | { accepted: true } @@ -193,18 +194,28 @@ const DEFAULT_WATERMARKS: StructuredAgentSessionSinkWatermarks = { maxLifecycleQueuedOperations: 1_024 } -export function createDeferredStructuredAgentSessionEventSink( - deps: { - onError?: (error: unknown) => void - watermarks?: Partial - readingControl?: StructuredAgentSessionReadingControl - onBackpressureChange?: (backpressured: boolean, state: StructuredAgentSessionSinkState) => void - } = {} -): DeferredStructuredAgentSessionEventSink { +export function createDeferredStructuredAgentSessionEventSink(deps: { + /** The session this sink writes for, named in every failure it logs. */ + sessionId: string + logger: StructuredAgentSessionLogger + /** The sink failed for good; the owner decides what that costs the provider. */ + onFailed?: (error: unknown) => void + watermarks?: Partial + readingControl?: StructuredAgentSessionReadingControl + onBackpressureChange?: (backpressured: boolean, state: StructuredAgentSessionSinkState) => void +}): DeferredStructuredAgentSessionEventSink { const watermarks = { ...DEFAULT_WATERMARKS, ...deps.watermarks } + const failed = (error: unknown): void => { + deps.logger.error('writing provider events to the chat journal failed', { + scope: 'journal-event-sink', + sessionId: deps.sessionId, + error + }) + deps.onFailed?.(error) + } const queue = new StructuredAgentSessionSinkQueue({ watermarks, - ...(deps.onError ? { onError: deps.onError } : {}), + onFailed: failed, ...(deps.readingControl ? { readingControl: deps.readingControl } : {}), ...(deps.onBackpressureChange ? { onBackpressureChange: deps.onBackpressureChange } : {}) }) @@ -280,7 +291,7 @@ export function createDeferredStructuredAgentSessionEventSink( appendLifecycleBatch: (settlementId, mutations, options = {}) => { const admission = appendLifecycleBatch(settlementId, mutations, options) if (!admission.accepted) { - deps.onError?.( + failed( new Error( `lifecycle journal batch ${settlementId} rejected by sink ${admission.reason}` ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts index 38349f95115..236dc7bc54b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -10,12 +10,15 @@ import { AgentSessionPreSpawnError } from './structured-agent-session-adapter' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' function context(): StructuredAgentSessionEvictionContext & { order: string[] } { const order: string[] = [] return { order, sessionId: 'session-1', + logger: createStructuredAgentSessionLogger(), eventSink: { unbind: vi.fn(() => order.push('unbind')), drained: vi.fn(async () => { @@ -44,9 +47,11 @@ function context(): StructuredAgentSessionEvictionContext & { order: string[] } } function runtimeState(): StructuredAgentSessionHostRuntimeState { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: eviction against the sink cache reads only the sinks and the logger; store and adapter are never reached. return new StructuredAgentSessionHostRuntimeState({ - store: {} as never, - adapter: {} as never + store: {}, + adapter: {}, + logger: recordingStructuredAgentSessionLogger().logger } as never) } @@ -146,6 +151,7 @@ describe('rows the provider emits while closing', () => { await evictStructuredAgentSession({ sessionId, + logger: recordingStructuredAgentSessionLogger().logger, eventSink: sink, adapter: { closeSession: async () => { @@ -227,6 +233,7 @@ describe('eviction against the real sink cache', () => { const sessionId = 'session-reattach' await evictStructuredAgentSession({ sessionId, + logger: recordingStructuredAgentSessionLogger().logger, eventSink: state.eventSinkFor(sessionId), adapter: { closeSession: async () => true } as never, discardSink: () => state.discardEventSink(sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts index 182d2e70192..7dde43d165d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts @@ -25,6 +25,7 @@ import { stopAgentSessionProviderRoot } from './structured-agent-session-provide import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { StructuredAgentSessionStopVerdict } from './structured-agent-session-host-types' import { withTimeout } from '../../../shared/promise-timeout-fallback' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionEvictionContext = { sessionId: string @@ -33,6 +34,7 @@ export type StructuredAgentSessionEvictionContext = { hasProviderChild?: boolean eventSink: DeferredStructuredAgentSessionEventSink adapter: StructuredAgentSessionAdapter + logger: StructuredAgentSessionLogger /** Tells the adapter the released lease is done with, so it drops this child's route and index. * The conversation stays: stopping the agent never closes its journal. */ acknowledgeRelease: () => Promise | void @@ -76,7 +78,10 @@ export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSe try { context.beforeProviderChildStop() } catch { - console.warn('[structured-agent-session] capturing recovery witness failed') + context.logger.warn('capturing a recovery witness before a stop failed', { + scope: 'recovery-witness', + sessionId: context.sessionId + }) } } }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index d1a45fc469a..fca04e1028a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -23,6 +23,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail' @@ -51,6 +52,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts index 06538d78310..38b85ca12d1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts @@ -21,6 +21,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): claude: not signed in' @@ -47,6 +48,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index fdaa5b72d50..c0e78b5a0c5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -24,6 +24,7 @@ import { attachStructuredAgentSession } from './structured-agent-session-attach- import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' // Everything before the journal is out of scope here; what matters is what the orchestration does // when the attach throws after acquisition. @@ -146,6 +147,7 @@ async function workingSession(): Promise<{ const server = new AgentHookServer() const records = new Map([[SESSION, ownerRecord()]]) const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions, getRecord: (sessionId) => records.get(sessionId) ?? null, now: () => 1, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts index b18279392b9..b82499c2830 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -24,6 +24,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -133,6 +134,7 @@ beforeEach(async () => { answerPrompt = vi.fn(async ({ commit }) => commit()) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 5d8a4d0429b..f2f885e0763 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -78,7 +78,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { deps.store.getRecord(sessionId), sessions.get(sessionId)?.journal ), - onError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }), + logger: deps.logger, record: (sessionId) => deps.store.getRecord(sessionId), readChildWork: input.clientDelivery.readChildWork, flushStreamedEvents: input.flushStreamedEvents, @@ -116,7 +116,11 @@ export function createStructuredAgentSessionConversationDelivery(input: { }) .catch((error: unknown) => { wakesQueued.delete(sessionId) - deps.onEventSinkError?.({ sessionId, error }) + deps.logger.warn('waking the delivery loop after a commit failed', { + scope: 'delivery-wake', + sessionId, + error + }) }) } // A chat open before its owner's death was proven revises what its open settled. Queued, never @@ -129,7 +133,13 @@ export function createStructuredAgentSessionConversationDelivery(input: { resettleOpenStructuredAgentSessionConversation(deps, sessionId, sessions.get(sessionId)) ) ) - .catch((error: unknown) => deps.onEventSinkError?.({ sessionId, error })) + .catch((error: unknown) => + deps.logger.warn('resettling an open chat after its owner died failed', { + scope: 'death-evidence-resettle', + sessionId, + error + }) + ) } }) return { @@ -158,8 +168,12 @@ async function settleInterruptedCommands( ): Promise { const fence = structuredAgentSessionConversationFence(deps.store, sessionId) try { - await recoverStructuredRewind(deps.store, sessionId, session.journal, fence) + await recoverStructuredRewind(deps, sessionId, session.journal, fence) } catch (error) { - deps.onEventSinkError?.({ sessionId, error }) + deps.logger.warn('settling an interrupted rewind on open failed', { + scope: 'rewind-recovery', + sessionId, + error + }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 719540da5da..a2f38c2e6bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -47,7 +47,7 @@ export type StructuredAgentSessionLifetimeContext = { } } -type ConversationCloseDeps = Pick & { +type ConversationCloseDeps = Pick & { store: Pick } @@ -70,7 +70,11 @@ export async function abandonQueuedStructuredAgentSessionMessages( .then( () => true, (error: unknown) => { - deps.onEventSinkError?.({ sessionId, error }) + deps.logger.warn('rejecting queued messages of a closed chat failed', { + scope: 'queued-abandon', + sessionId, + error + }) return false } ) @@ -113,7 +117,6 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( const owed = owedProviderChildWindDown(session) session.owesProviderChildWindDown = owed ? { ...owed, cause } : undefined const stopping = session.child - let settlementError: unknown const eviction: StructuredAgentSessionEvictionContext = { sessionId, // The retry must not re-stop a child the adapter already proved gone, so this stays honest. @@ -121,6 +124,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( owesProviderChildWindDown: owed !== undefined, eventSink: context.runtimeState.eventSinkFor(sessionId), adapter: context.deps.adapter, + logger: context.deps.logger, // The adapter settles its own open turn with this, so who asked travels with the stop. stopCause: cause, ...(context.restartWitness @@ -153,15 +157,16 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( pendingSubmissionReason: 'provider_closed_before_acknowledgement', // Only a turn no adapter settled: one with no close, or whose settle threw. verdict: turnVerdictForChildEnd(cause, context.now()), - showUnexpectedExitOutcome: false, - onError: (id, error) => { - settlementError = error - context.deps.onEventSinkError?.({ sessionId: id, error }) - } + showUnexpectedExitOutcome: false }) - if (!settled) { + if (!settled.ok) { + context.deps.logger.warn("settling a closed agent's work failed", { + scope: 'close-settlement', + sessionId, + error: settled.error + }) // Without the cause the log names the step and nothing else. - throw new Error('dead generation work settlement failed', { cause: settlementError }) + throw new Error('dead generation work settlement failed', { cause: settled.error }) } }, releaseLease: async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts index 5f336345b5e..e8421b02893 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts @@ -13,6 +13,8 @@ import { } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const NOW = 1_800_000_000_000 @@ -68,7 +70,8 @@ function runtimeState( adapter: {}, journalDatabase: openTestJournalHostDatabase(stateDirectory), claimKeyId: 'key-1', - probeOwner + probeOwner, + logger: createStructuredAgentSessionLogger() } as StructuredAgentSessionHostDeps return new StructuredAgentSessionHostRuntimeState(deps) } @@ -129,7 +132,7 @@ describe('host runtime-state owner probe', () => { it('does not force-close a provider for transient lease probe errors', async () => { const onEventSinkFailure = vi.fn() - const onEventSinkError = vi.fn() + const log = recordingStructuredAgentSessionLogger() const probeOwner = vi.fn(async () => { throw new Error('lease probe unavailable') }) @@ -144,7 +147,7 @@ describe('host runtime-state owner probe', () => { journalDatabase: openTestJournalHostDatabase(stateDirectory), claimKeyId: 'key-1', probeOwner, - onEventSinkError + logger: log.logger } as unknown as StructuredAgentSessionHostDeps const state = new StructuredAgentSessionHostRuntimeState(deps, onEventSinkFailure) @@ -152,10 +155,15 @@ describe('host runtime-state owner probe', () => { state as unknown as { leaseRenewer: { renewNow: () => Promise } } ).leaseRenewer.renewNow() - expect(onEventSinkError).toHaveBeenCalledWith({ - sessionId: record.sessionId, - error: expect.any(Error) - }) + expect(log.entries).toContainEqual( + expect.objectContaining({ + fields: expect.objectContaining({ + scope: 'lease-renewal', + sessionId: record.sessionId, + error: expect.any(Error) + }) + }) + ) expect(onEventSinkFailure).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts index 63f37a37590..1df3bc34909 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts @@ -26,7 +26,7 @@ export class StructuredAgentSessionHostRuntimeState { now: () => deps.now?.() ?? Date.now(), // Lease/ownership failures are transient and stay on the visible lease-error path. // Only deferred sink I/O failures are terminal and may force-close a provider. - onError: ({ sessionId, error }) => deps.onEventSinkError?.({ sessionId, error }) + logger: deps.logger }) } @@ -67,8 +67,9 @@ export class StructuredAgentSessionHostRuntimeState { mintEventSink(sessionId: string): DeferredStructuredAgentSessionEventSink { const minted: DeferredStructuredAgentSessionEventSink = createDeferredStructuredAgentSessionEventSink({ - onError: (error) => { - this.deps.onEventSinkError?.({ sessionId, error }) + sessionId, + logger: this.deps.logger, + onFailed: (error) => { // Only the session's own sink may force its provider down; an attempt's never is. if (this.eventSinks.get(sessionId) === minted) { this.onEventSinkFailure?.(sessionId, error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts index 8a18040daf3..5c9f5215a95 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts @@ -1,4 +1,5 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' /** * Chat-tab visibility is the deletion funnel: every path that removes a chat as a user-facing @@ -9,6 +10,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' export function setStructuredAgentSessionTabVisibility( host: { deps: { + logger: StructuredAgentSessionLogger store: { setSessionTabVisibility: ( sessionId: string, @@ -25,7 +27,10 @@ export function setStructuredAgentSessionTabVisibility( ): Promise { if (!visible) { void host.restartResume.dismiss([sessionId]).catch(() => { - console.warn('[structured-agent-session] forgetting recovery records on chat close failed') + host.deps.logger.warn('forgetting recovery records on chat close failed', { + scope: 'tab-close-recovery-dismiss', + sessionId + }) }) } return host.deps.store.setSessionTabVisibility(sessionId, visible, tabId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts index aa8692a0c73..e6f62d6d780 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts @@ -13,12 +13,15 @@ import { RESUME_MARKER_RECORD_TIMEOUT_MS, structuredAgentSessionHostTeardownPhases } from './structured-agent-session-host-teardown' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const noop = async (): Promise => undefined describe('structured agent-session host teardown', () => { it('names every phase, so the quit-path order is pinned rather than incidental', () => { const phases = structuredAgentSessionHostTeardownPhases({ + logger: createStructuredAgentSessionLogger(), idleSweep: { dispose: noop }, runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, tasks: { drainAttaches: noop }, @@ -62,6 +65,7 @@ describe('structured agent-session host teardown', () => { it('ends child eviction as soon as every chat has closed, not at its bound', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const evict = structuredAgentSessionHostTeardownPhases({ + logger: createStructuredAgentSessionLogger(), idleSweep: { dispose: noop }, runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, tasks: { drainAttaches: noop }, @@ -86,11 +90,12 @@ describe('structured agent-session host teardown', () => { it('bounds stalled recovery publication without preventing later cleanup', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const log = recordingStructuredAgentSessionLogger() const pending = Promise.withResolvers() const cleaned = vi.fn(async () => {}) const flush = vi.fn(async () => cleaned()) const phases = structuredAgentSessionHostTeardownPhases({ + logger: log.logger, idleSweep: { dispose: cleaned }, runtimeState: { stopLeaseRenewal: () => {}, flushAllEventSinks: flush }, tasks: { drainAttaches: cleaned }, @@ -107,13 +112,10 @@ describe('structured agent-session host teardown', () => { await vi.advanceTimersByTimeAsync(2000) await teardown expect(cleaned).toHaveBeenCalledTimes(4) - expect(warning).toHaveBeenCalledWith( - '[structured-agent-session] recording recovery capsule failed' - ) + expect(log.scopes()).toEqual(['teardown-recovery-capsule']) expect(vi.getTimerCount()).toBe(0) } finally { pending.resolve() - warning.mockRestore() vi.useRealTimers() } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index e6700777227..65bf219cf79 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -17,6 +17,7 @@ import { } from './structured-agent-session-host-lifetime' import { withTimeout } from '../../../shared/promise-timeout-fallback' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionTeardownPhase = { name: string @@ -66,6 +67,7 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { /** Opens this teardown's witnesses; each session's own is taken as eviction stops its child. */ beginResumeMarkers: () => void recordResumeMarkers: () => Promise + logger: StructuredAgentSessionLogger }): StructuredAgentSessionTeardownPhase[] { return [ { @@ -74,7 +76,9 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { try { collaborators.beginResumeMarkers() } catch { - console.warn('[structured-agent-session] capturing recovery witnesses failed') + collaborators.logger.warn('capturing recovery witnesses for teardown failed', { + scope: 'teardown-recovery-witnesses' + }) } } }, @@ -90,7 +94,9 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { run: () => withPhaseTimeout(collaborators.recordResumeMarkers, RESUME_MARKER_RECORD_TIMEOUT_MS).catch( () => { - console.warn('[structured-agent-session] recording recovery capsule failed') + collaborators.logger.warn('recording the recovery capsule at teardown failed', { + scope: 'teardown-recovery-capsule' + }) } ) }, @@ -168,7 +174,8 @@ export async function flushStructuredAgentSessionHost( retainSessionIds ), beginResumeMarkers: () => context.restartResume.beginTeardown(context.trigger), - recordResumeMarkers: context.restartResume.recordMarkers + recordResumeMarkers: context.restartResume.recordMarkers, + logger: context.deps.logger }), sessions: context.sessions, retainSessionIds, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts index 2971ce4f79c..ee0bb2bee92 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts @@ -19,6 +19,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -66,6 +67,7 @@ describe('abandoning a structured agent-session host', () => { setOption: async () => undefined } const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter, probeOwner: async () => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index f29d1b68e69..c7667531110 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -29,6 +29,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const journals = createTrackedJournalOpener() @@ -62,6 +63,7 @@ let root: string let recoveryCapsule: TrackedTestRecoveryCapsule let store: AgentSessionRecordStore let host: StructuredAgentSessionHost +let log: ReturnType let acquire: Mock let releaseAcquisition: Mock> let dispatch: Mock @@ -152,7 +154,9 @@ beforeEach(async () => { setOption = vi.fn(async () => undefined) store = await openTestAgentSessionRecordStore(root) recoveryCapsule = new TrackedTestRecoveryCapsule(root) + log = recordingProductionStructuredAgentSessionLogger() host = new StructuredAgentSessionHost({ + logger: log.logger, store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), @@ -200,6 +204,8 @@ export function hostTestState() { root, store, host, + /** Every entry the beforeEach host logged; a host a test builds itself logs elsewhere. */ + log, acquire, releaseAcquisition, dispatch, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index c2ec1833288..dcf708643e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -17,6 +17,7 @@ import type { import type { AgentSessionAttachParams } from './structured-agent-session-attach' import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' import type { AgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionCaller = { callerKey: string } @@ -121,11 +122,9 @@ export type StructuredAgentSessionHostDeps = { idleSweep?: { intervalMs?: number; idleMs?: number } /** Whether an orchestration dispatch still owns this session's worker; absent answers no. */ hasOpenDispatch?: (record: AgentSessionRecord) => boolean - onEventSinkError?: (input: { sessionId: string; error: unknown }) => void - /** Lease bookkeeping run for startup or a read (the reconcile, or resolving a chat's recovery) - * that refused or threw, once per distinct failure. Startup and the read carry on: the next - * attach or send reconciles and resolves recovery again before it acts. */ - onLeaseReconcileFailure?: (failure: unknown) => void + /** Where every failure the host carries on past is reported. Required: a host without one would + * drop exactly the failures nobody sees in the UI. */ + logger: StructuredAgentSessionLogger /** Every status projection this host publishes. `replay` marks a re-projection of state the host * already knew (restore, an arriving subscriber) rather than a fresh journal edge. */ onSessionStatusChanged?: ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 4b1993ea302..d666e4a5c94 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -28,6 +28,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' let root: string let store: AgentSessionRecordStore @@ -132,6 +133,7 @@ describe('attach', () => { } })) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter(), acquire }, journalDatabase: openTestJournalHostDatabase(root), @@ -562,6 +564,7 @@ describe('restart', () => { ) { store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter(), ...adapterOverrides }, journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 37f4a196610..95cc822bba1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -56,6 +56,7 @@ import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent- import { createStructuredAgentSessionConversationDelivery } from './structured-agent-session-host-delivery' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { wireStructuredAgentSessionQueuedMessages } from './structured-agent-session-queued-wiring' +import * as sessionLogger from './structured-agent-session-logger' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' export class StructuredAgentSessionHost { @@ -68,7 +69,7 @@ export class StructuredAgentSessionHost { this.subscribers.publish(sessionId, journal) this.conversationDelivery.afterCommit(sessionId, journal) }, - onDeliveryError: (sessionId, error) => this.deps.onEventSinkError?.({ sessionId, error }), + logger: sessionLogger.deferredStructuredAgentSessionLogger(() => this.deps.logger), onOpened: (sessionId) => this.queued.drain.schedule(sessionId), now: () => this.now() }) @@ -101,6 +102,8 @@ export class StructuredAgentSessionHost { readonly restartResume: StructuredAgentSessionRestartResume constructor(readonly deps: StructuredAgentSessionHostDeps) { + // Every collaborator reads this copy, so a logger that throws cannot fail what it reports. + this.deps = deps = sessionLogger.withNeverThrowingLogger(deps) this.clientDelivery.watchAtRestCommands(deps.adapter) this.backgroundTasks = new StructuredAgentSessionBackgroundTaskChannel( deps, @@ -158,8 +161,7 @@ export class StructuredAgentSessionHost { ), publishStatus: this.clientDelivery.publishStatusAndSettlement, serialize: (sessionId, task) => this.tasks.trackAttach(this.serialize(sessionId, task)), - now: () => this.now(), - onBarrierError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) + now: () => this.now() }) this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, { ...structuredAgentSessionRestartResumeSurfaces(this, this.now), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index cad1d17d94f..8ff4cdbeb42 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -320,8 +320,14 @@ describe('the idle sweep with no child running (P2-22 ii)', () => { stopAgent, stopStartingAgent: stopAgent, closeConversation, - onError: (_id, error) => { - throw error + // A failed step fails the test. + logger: { + warn: (_message, fields) => { + throw fields.error + }, + error: (_message, fields) => { + throw fields.error + } } }) await sweep.tick() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 14cc0e56030..e9b0e1b45c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -14,6 +14,7 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-qu import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export const STRUCTURED_AGENT_SESSION_IDLE_SWEEP_INTERVAL_MS = 5 * 60_000 export const STRUCTURED_AGENT_SESSION_IDLE_MS = 30 * 60_000 @@ -37,7 +38,7 @@ export type StructuredAgentSessionIdleSweepDeps = { stopAgent: (sessionId: string) => Promise stopStartingAgent: (sessionId: string) => Promise closeConversation: (sessionId: string) => Promise - onError: (sessionId: string, error: unknown) => void + logger: StructuredAgentSessionLogger intervalMs?: number idleMs?: number } @@ -87,7 +88,13 @@ export class StructuredAgentSessionIdleSweep { [...this.deps.sessions.keys()].map((sessionId) => this.deps .serialize(sessionId, () => this.tickUnderSerialize(sessionId)) - .catch((error: unknown) => this.deps.onError(sessionId, error)) + .catch((error: unknown) => + this.deps.logger.warn('an idle sweep step failed', { + scope: 'idle-sweep', + sessionId, + error + }) + ) ) ) } finally { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts index a173a3ad661..5a384ce2bb9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts @@ -25,6 +25,7 @@ import { HOST_TEST_SESSION as SESSION, hostTestMessage } from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const { copy } = vi.hoisted(() => ({ copy: { mismatched: false } })) @@ -93,6 +94,7 @@ async function relaunchWithMismatchedCopy(): Promise }) const store = await openTestAgentSessionRecordStore(relaunched) const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(relaunched), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts index ac4bb5e2f4d..bd7d735abbc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts @@ -16,6 +16,9 @@ import { } from './structured-agent-session-host-test-harness' import { hostTestMessage } from './structured-agent-session-host-test-data' +const STOP_LEDGER_ROW_FAILED = + "[agent-session] stop-ledger-row: writing Stop's ledger row failed; Stop runs without it" + let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost @@ -70,9 +73,10 @@ it('refuses a send as corrupt when SQLite reports damage, and still stops the ag // the caller sees, after the fact. await expect(stop()).rejects.toBe(damaged) expect(cancelTurn).toHaveBeenCalledTimes(1) - expect(warn).toHaveBeenCalledWith("[agent-session] Stop's ledger row skipped:", { + expect(warn).toHaveBeenCalledWith(STOP_LEDGER_ROW_FAILED, { + scope: 'stop-ledger-row', sessionId: expect.any(String), - error: 'database disk image is malformed' + error: expect.objectContaining({ message: 'database disk image is malformed' }) }) // The restart-offer withdrawal the attach started holds its lock until it ends. await hostTestRecoveryCapsuleSettled() @@ -93,9 +97,10 @@ it.each([ value: { turnId: 'turn-1', cancelled: true } }) expect(cancelTurn).toHaveBeenCalledTimes(1) - expect(warn).toHaveBeenCalledWith("[agent-session] Stop's ledger row skipped:", { + expect(warn).toHaveBeenCalledWith(STOP_LEDGER_ROW_FAILED, { + scope: 'stop-ledger-row', sessionId: expect.any(String), - error: error.message + error }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 44615a27ee8..f91fc57c055 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -29,6 +29,7 @@ import { import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -95,6 +96,7 @@ beforeEach(async () => { closeSession = vi.fn(async () => true) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire: vi.fn(async ({ fence }) => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts index 9399e4d0242..c3eb1e324e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts @@ -9,6 +9,7 @@ import { import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const NOW = 1_800_000_000_000 const roots: string[] = [] @@ -103,6 +104,7 @@ describe('structured agent-session lease renewal', () => { ) ) const renewer = new StructuredAgentSessionLeaseRenewer({ + logger: recordingStructuredAgentSessionLogger().logger, store: { listRecords: () => records, renewLeases } as unknown as AgentSessionRecordStore, probe: vi.fn(), probeMany, @@ -144,7 +146,7 @@ describe('structured agent-session lease renewal', () => { } return records[0]! }) - const onError = vi.fn() + const log = recordingStructuredAgentSessionLogger() const renewer = new StructuredAgentSessionLeaseRenewer({ store: { listRecords: () => records, @@ -156,14 +158,15 @@ describe('structured agent-session lease renewal', () => { matchedOn: ['spawn-token' as const] }), now: () => NOW + 10_000, - onError + logger: log.logger }) await renewer.renewNow() expect(renewLeases).toHaveBeenCalledOnce() expect(renewLease).toHaveBeenCalledTimes(2) - expect(onError).toHaveBeenCalledWith({ + expect(log.entries.map((entry) => entry.fields)).toContainEqual({ + scope: 'lease-renewal', sessionId: 'session-b', error: expect.objectContaining({ message: 'agent_session_checkpoint_stale' }) }) @@ -174,6 +177,7 @@ describe('structured agent-session lease renewal', () => { const store = await liveStore() let now = NOW const renewer = new StructuredAgentSessionLeaseRenewer({ + logger: recordingStructuredAgentSessionLogger().logger, store, probe: async () => ({ outcome: 'identity-matched', @@ -203,6 +207,7 @@ describe('structured agent-session lease renewal', () => { releaseProbe = resolve }) const renewer = new StructuredAgentSessionLeaseRenewer({ + logger: recordingStructuredAgentSessionLogger().logger, store, probe: async () => { await probing @@ -228,6 +233,7 @@ describe('structured agent-session lease renewal', () => { matchedOn: ['process-start-time' as const] })) const renewer = new StructuredAgentSessionLeaseRenewer({ + logger: recordingStructuredAgentSessionLogger().logger, store, probe, now: () => NOW + 10_000 @@ -241,18 +247,19 @@ describe('structured agent-session lease renewal', () => { it('stops extending the lease when child proof is no longer sufficient', async () => { const store = await liveStore() - const onError = vi.fn() + const log = recordingStructuredAgentSessionLogger() const renewer = new StructuredAgentSessionLeaseRenewer({ store, probe: async () => ({ outcome: 'indeterminate', reason: 'probe unavailable' }), now: () => NOW + 10_000, - onError + logger: log.logger }) await renewer.renewNow() expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW) - expect(onError).toHaveBeenCalledWith({ + expect(log.entries.map((entry) => entry.fields)).toContainEqual({ + scope: 'lease-renewal', sessionId: 'session-renewal', error: expect.any(Error) }) @@ -271,6 +278,7 @@ describe('structured agent-session lease renewal', () => { matchedOn: ['process-start-time' as const] })) const renewer = new StructuredAgentSessionLeaseRenewer({ + logger: recordingStructuredAgentSessionLogger().logger, store, probe, now: () => NOW + 10_000 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts index 21d79020270..7888d4867e2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts @@ -4,13 +4,14 @@ import { AGENT_SESSION_LEASE_TTL_MS, type AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' const RENEW_INTERVAL_MS = Math.floor(AGENT_SESSION_LEASE_TTL_MS / 3) export class StructuredAgentSessionLeaseRenewer { private timer: ReturnType | null = null private running = false - /** The tick in flight. Never rejects: the timer path reports renewal failures through `onError`, + /** The tick in flight. Never rejects: the timer path logs renewal failures, * and stopping must not turn one into a teardown failure as well. */ private inFlight: Promise = Promise.resolve() @@ -22,7 +23,7 @@ export class StructuredAgentSessionLeaseRenewer { records: readonly AgentSessionRecord[] ) => Promise> now: () => number - onError?: (input: { sessionId: string; error: unknown }) => void + logger: StructuredAgentSessionLogger intervalMs?: number } ) {} @@ -106,7 +107,7 @@ export class StructuredAgentSessionLeaseRenewer { if (result.status === 'rejected') { const renewal = renewals[index] if (renewal) { - this.input.onError?.({ sessionId: renewal.sessionId, error: result.reason }) + this.reportFailure(renewal.sessionId, result.reason) } } }) @@ -126,15 +127,24 @@ export class StructuredAgentSessionLeaseRenewer { if (result.status === 'fulfilled') { probes.set(record.sessionId, result.value) } else { - this.input.onError?.({ sessionId: record.sessionId, error: result.reason }) + this.reportFailure(record.sessionId, result.reason) } } return probes } catch (error) { for (const record of records) { - this.input.onError?.({ sessionId: record.sessionId, error }) + this.reportFailure(record.sessionId, error) } return new Map() } } + + /** Lease and ownership failures are transient: the next tick, attach or send retries them. */ + private reportFailure(sessionId: string, error: unknown): void { + this.input.logger.warn('renewing a chat lease failed', { + scope: 'lease-renewal', + sessionId, + error + }) + } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index 706d0d8fc3f..c25640cc81c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -24,6 +24,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -42,6 +43,7 @@ let dispatch: Mock function openHost(): void { host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-log-repeats.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-log-repeats.ts new file mode 100644 index 00000000000..d805a2191d2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-log-repeats.ts @@ -0,0 +1,54 @@ +// Keeps one failure that recurs on a timer (lease renewal, idle sweep) or on every publication +// (status feed) from flooding the shared, rotated trace file and erasing the history around it. + +/** How long a repeated entry stays quiet after it is written. */ +export const STRUCTURED_AGENT_SESSION_LOG_REPEAT_WINDOW_MS = 5 * 60_000 +const MAX_TRACKED_REPEATS = 256 + +type TrackedRepeat = { writtenAt: number; suppressed: number } + +export type StructuredAgentSessionLogRepeats = { + /** `null` swallows this entry; a number writes it, carrying the repeats swallowed since the last. */ + admit: (key: string) => number | null +} + +export function createStructuredAgentSessionLogRepeats(options?: { + now?: () => number + windowMs?: number + maxTracked?: number +}): StructuredAgentSessionLogRepeats { + const now = options?.now ?? Date.now + const windowMs = options?.windowMs ?? STRUCTURED_AGENT_SESSION_LOG_REPEAT_WINDOW_MS + const maxTracked = options?.maxTracked ?? MAX_TRACKED_REPEATS + // Insertion order is write order, so the first key is the one written longest ago. + const tracked = new Map() + + const makeRoom = (at: number): void => { + for (const [key, repeat] of tracked) { + if (at - repeat.writtenAt >= windowMs) { + tracked.delete(key) + } + } + const oldest = tracked.keys().next() + if (tracked.size >= maxTracked && !oldest.done) { + tracked.delete(oldest.value) + } + } + + return { + admit: (key) => { + const at = now() + const repeat = tracked.get(key) + if (repeat && at - repeat.writtenAt < windowMs) { + repeat.suppressed += 1 + return null + } + tracked.delete(key) + if (tracked.size >= maxTracked) { + makeRoom(at) + } + tracked.set(key, { writtenAt: at, suppressed: 0 }) + return repeat?.suppressed ?? 0 + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-logger-test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-logger-test-support.ts new file mode 100644 index 00000000000..f4413c4d11b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-logger-test-support.ts @@ -0,0 +1,58 @@ +import { + createStructuredAgentSessionLogger, + type StructuredAgentSessionLogFields, + type StructuredAgentSessionLogger +} from './structured-agent-session-logger' + +export type RecordedStructuredAgentSessionLog = { + level: keyof StructuredAgentSessionLogger + message: string + fields: StructuredAgentSessionLogFields +} + +/** A logger that keeps every entry, for tests that assert a failure was reported. */ +export function recordingStructuredAgentSessionLogger(): { + logger: StructuredAgentSessionLogger + entries: RecordedStructuredAgentSessionLog[] + scopes: () => string[] +} { + const entries: RecordedStructuredAgentSessionLog[] = [] + return { + logger: { + warn: (message, fields) => entries.push({ level: 'warn', message, fields }), + error: (message, fields) => entries.push({ level: 'error', message, fields }) + }, + entries, + scopes: () => entries.map((entry) => entry.fields.scope) + } +} + +/** A recording logger that also prints as production does, for a harness whose tests read either: + * the entries hold every level and every field, whatever the console mirror keeps. */ +export function recordingProductionStructuredAgentSessionLogger(): ReturnType< + typeof recordingStructuredAgentSessionLogger +> { + const recording = recordingStructuredAgentSessionLogger() + const production = createStructuredAgentSessionLogger() + return { + ...recording, + logger: { + warn: (message, fields) => { + recording.logger.warn(message, fields) + production.warn(message, fields) + }, + error: (message, fields) => { + recording.logger.error(message, fields) + production.error(message, fields) + } + } + } +} + +/** What an event sink built outside a host needs: the session it writes for, and a logger. */ +export function testEventSinkLogging(sessionId = 'session-1'): { + sessionId: string + logger: StructuredAgentSessionLogger +} { + return { sessionId, logger: recordingStructuredAgentSessionLogger().logger } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-logger.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-logger.test.ts new file mode 100644 index 00000000000..0cacbda8587 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-logger.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { _resetTracerForTests, setActiveSink } from '../../observability/tracer' +import { + AgentSessionRefusalError, + agentSessionRefusalError, + refuse +} from '../../../shared/agent-session-wire-refusals' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { + STRUCTURED_AGENT_SESSION_LOG_REPEAT_WINDOW_MS as WINDOW_MS, + createStructuredAgentSessionLogRepeats +} from './structured-agent-session-log-repeats' + +let push: Mock<(record: unknown) => void> +let clock: number + +function written(): { name: string; attributes: Record; exit: unknown }[] { + // As the sink writes them: one JSON line per span. + return JSON.parse(JSON.stringify(push.mock.calls.map(([span]) => span))) +} + +beforeEach(() => { + push = vi.fn<(record: unknown) => void>() + clock = 1_000_000 + setActiveSink({ push, flush: () => {}, close: () => {} }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + vi.spyOn(console, 'error').mockImplementation(() => undefined) +}) + +afterEach(() => { + _resetTracerForTests() + vi.restoreAllMocks() +}) + +describe('a failure that repeats', () => { + it('is written once per window, then once more carrying how many repeats it swallowed', () => { + const logger = createStructuredAgentSessionLogger({ now: () => clock }) + const renew = (): void => + logger.warn('renewing a chat lease failed', { + scope: 'lease-renewal', + sessionId: 'session-1', + error: new Error('database is locked') + }) + + for (let tick = 0; tick < 30; tick += 1) { + renew() + clock += 10_000 + } + expect(written()).toHaveLength(1) + expect(written()[0]?.attributes).not.toHaveProperty('suppressed') + expect(console.warn).toHaveBeenCalledOnce() + + clock += WINDOW_MS + renew() + expect(written()).toHaveLength(2) + expect(written()[1]?.attributes).toMatchObject({ suppressed: 29, sessionId: 'session-1' }) + expect(console.warn).toHaveBeenLastCalledWith( + '[agent-session] lease-renewal: renewing a chat lease failed', + expect.objectContaining({ suppressed: 29 }) + ) + + renew() + expect(written()).toHaveLength(2) + }) + + it('never merges different sessions, scopes, levels, messages or errors', () => { + const logger = createStructuredAgentSessionLogger({ now: () => clock }) + for (let round = 0; round < 3; round += 1) { + logger.warn('renewing a chat lease failed', { scope: 'lease-renewal', sessionId: 'a' }) + logger.warn('renewing a chat lease failed', { scope: 'lease-renewal', sessionId: 'b' }) + logger.warn('renewing a chat lease failed', { scope: 'idle-sweep', sessionId: 'a' }) + logger.error('renewing a chat lease failed', { scope: 'lease-renewal', sessionId: 'a' }) + logger.warn('a different step failed', { scope: 'lease-renewal', sessionId: 'a' }) + logger.warn('a different step failed', { + scope: 'lease-renewal', + sessionId: 'a', + error: new Error('disk full') + }) + } + + expect(written().map((span) => [span.name, span.attributes['sessionId']])).toEqual([ + ['agentSession.lease-renewal', 'a'], + ['agentSession.lease-renewal', 'b'], + ['agentSession.idle-sweep', 'a'], + ['agentSession.lease-renewal', 'a'], + ['agentSession.lease-renewal', 'a'], + ['agentSession.lease-renewal', 'a'] + ]) + }) + + it('writes a failure whose cause, refusal reason or value differs, and swallows a true repeat', () => { + const logger = createStructuredAgentSessionLogger({ now: () => clock }) + // A refusal's message is its bare code: only the cause tells these two apart. + const unreadable = (cause: Error): Error => + new AgentSessionRefusalError( + refuse( + 'agent_session_journal_unreadable', + { reason: 'journalUnavailable' }, + 'agent_session_journal_unreadable' + ), + { cause } + ) + const report = (error: unknown): void => + logger.warn('recording an opened chat tab failed', { + scope: 'tab-visibility-open', + sessionId: 'session-1', + error + }) + const denied = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }) + const failing = Object.assign(new Error('EIO: i/o error'), { code: 'EIO' }) + + report(unreadable(denied)) + report(unreadable(denied)) + report(unreadable(failing)) + report( + agentSessionRefusalError('agent_session_journal_unreadable', { reason: 'journalCorrupt' }) + ) + report({ code: 'busy', attempt: 1 }) + report({ attempt: 1, code: 'busy' }) + report({ code: 'locked', attempt: 1 }) + + expect( + written().map((span) => span.attributes['errorCause'] ?? span.attributes['error']) + ).toEqual([ + ['Error: EACCES: permission denied'], + ['Error: EIO: i/o error'], + undefined, + { code: 'busy', attempt: 1 }, + { code: 'locked', attempt: 1 } + ]) + expect(written()[2]?.attributes).toMatchObject({ refusalReason: 'journalCorrupt' }) + expect(written()[0]?.attributes).toMatchObject({ refusalReason: 'journalUnavailable' }) + }) + + it('writes one message under another error name, or as a refusal, as separate failures', () => { + const logger = createStructuredAgentSessionLogger({ now: () => clock }) + const renew = (error: Error): void => + logger.warn('renewing a chat lease failed', { scope: 'lease-renewal', sessionId: 'a', error }) + const unreadableRecord = (): Error => new Error('execution_owner_reconciling') + const reconciling = (): Error => + agentSessionRefusalError('execution_owner_reconciling', { reason: 'hostReconciling' }) + + for (let tick = 0; tick < 3; tick += 1) { + renew(unreadableRecord()) + renew(reconciling()) + renew(new TypeError('execution_owner_reconciling')) + } + + expect(written().map((span) => span.exit)).toEqual([ + expect.objectContaining({ cause: expect.stringMatching(/^Error: /) }), + expect.objectContaining({ cause: expect.stringMatching(/^AgentSessionRefusalError: /) }), + expect.objectContaining({ cause: expect.stringMatching(/^TypeError: /) }) + ]) + }) + + it('tracks a bounded number of repeating entries', () => { + const repeats = createStructuredAgentSessionLogRepeats({ now: () => clock, maxTracked: 2 }) + expect(repeats.admit('a')).toBe(0) + expect(repeats.admit('b')).toBe(0) + expect(repeats.admit('a')).toBeNull() + // A third key evicts the one written longest ago, so `a` is written again. + expect(repeats.admit('c')).toBe(0) + expect(repeats.admit('a')).toBe(0) + expect(repeats.admit('c')).toBeNull() + }) +}) + +describe('what an Error leaves in the trace file', () => { + it('carries its code and its causes, depth-capped, by name and message only', () => { + const denied = Object.assign(new Error('EACCES: permission denied, open'), { + code: 'EACCES', + path: '/private/payload' + }) + const locked = Object.assign(new Error('database is locked'), { + code: 'ERR_SQLITE_ERROR', + errcode: 5 + }) + const chain = new Error('agent_session_store_corrupt', { + cause: new Error('read failed', { + cause: new Error('third', { cause: new Error('fourth', { cause: new Error('fifth') }) }) + }) + }) + + const logger = createStructuredAgentSessionLogger({ now: () => clock }) + logger.warn('importing legacy records failed', { scope: 'legacy-record-import', error: chain }) + logger.warn('opening the journal failed', { + scope: 'journal-database-open', + error: new Error('opening failed', { cause: denied }) + }) + logger.error('writing events failed', { scope: 'journal-event-sink', error: locked }) + + const [corrupt, open, sqlite] = written() + expect(corrupt?.attributes['errorCause']).toEqual([ + 'Error: read failed', + 'Error: third', + 'Error: fourth' + ]) + expect(open?.attributes['errorCause']).toEqual(['Error: EACCES: permission denied, open']) + expect(JSON.stringify(written())).not.toContain('/private/payload') + expect(sqlite?.attributes).toMatchObject({ errorCode: 'ERR_SQLITE_ERROR', errorErrcode: 5 }) + expect(sqlite?.exit).toMatchObject({ cause: expect.stringContaining('database is locked') }) + }) + + it('names the code of a cause whose message does not', () => { + const logger = createStructuredAgentSessionLogger({ now: () => clock }) + logger.warn('x failed', { + scope: 'x', + error: new Error('x', { cause: Object.assign(new Error('no space'), { code: 'ENOSPC' }) }) + }) + expect(written()[0]?.attributes['errorCause']).toEqual(['Error: no space [ENOSPC]']) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-logger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-logger.ts new file mode 100644 index 00000000000..1244e974532 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-logger.ts @@ -0,0 +1,173 @@ +// Where the structured chat host and its runtime report a failure they carry on past. +// +// One required dependency rather than a callback per failure: a host built without it does not +// compile, so no failure path can quietly drop what went wrong. The default writes each entry to +// the host's local trace file (the desktop's `/logs/main.trace.ndjson`, collected by the +// diagnostic bundle; orcad's `/logs/orcad.trace.ndjson`) and to the console. + +import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { startSpan } from '../../observability/tracer' +import { createStructuredAgentSessionLogRepeats } from './structured-agent-session-log-repeats' + +export type StructuredAgentSessionLogFields = { + /** The step that failed; a stable name a log search can find. */ + readonly scope: string + readonly sessionId?: string + readonly error?: unknown + readonly [field: string]: unknown +} + +export type StructuredAgentSessionLogger = { + warn: (message: string, fields: StructuredAgentSessionLogFields) => void + error: (message: string, fields: StructuredAgentSessionLogFields) => void +} + +type LogLevel = keyof StructuredAgentSessionLogger + +const guarded = new WeakSet() + +/** Reporting is bookkeeping: a logger that throws must never fail the operation it reports. */ +export function neverThrowingStructuredAgentSessionLogger( + logger: StructuredAgentSessionLogger +): StructuredAgentSessionLogger { + if (guarded.has(logger)) { + return logger + } + const call = + (level: LogLevel) => + (message: string, fields: StructuredAgentSessionLogFields): void => { + try { + logger[level](message, fields) + } catch (loggerError) { + try { + console.warn(`[agent-session] ${message}`, { ...fields, loggerError }) + } catch { + // Nothing is left to report to. + } + } + } + const safe: StructuredAgentSessionLogger = { warn: call('warn'), error: call('error') } + guarded.add(safe) + return safe +} + +/** A copy of `deps` whose logger cannot throw; every collaborator built from it inherits that. */ +export function withNeverThrowingLogger( + deps: T +): T { + return { ...deps, logger: neverThrowingStructuredAgentSessionLogger(deps.logger) } +} + +/** The logger `resolve` returns at each call, for a collaborator built before the host's deps. */ +export function deferredStructuredAgentSessionLogger( + resolve: () => StructuredAgentSessionLogger +): StructuredAgentSessionLogger { + return { + warn: (message, fields) => resolve().warn(message, fields), + error: (message, fields) => resolve().error(message, fields) + } +} + +/** The production logger: a failed span in the local trace file, plus the console. A repeat of + * the same entry is written at most once per window, carrying how many were swallowed. */ +export function createStructuredAgentSessionLogger(options?: { + now?: () => number +}): StructuredAgentSessionLogger { + const repeats = createStructuredAgentSessionLogRepeats({ now: options?.now }) + const write = + (level: LogLevel) => + (message: string, fields: StructuredAgentSessionLogFields): void => { + const { scope, error, ...rest } = fields + const attributes = { level, message, ...rest, ...errorAttributes(error) } + // Keyed on all the entry writes but its stack: only a failure writing the same entry repeats. + const suppressed = repeats.admit( + stableJson([ + scope, + attributes, + error instanceof Error ? `${error.name}: ${error.message}` : null + ]) + ) + if (suppressed === null) { + return + } + const span = startSpan(`agentSession.${scope}`, { + attributes: suppressed > 0 ? { ...attributes, suppressed } : attributes + }) + span.fail(error instanceof Error ? error : message) + const print = level === 'error' ? console.error : console.warn + print( + `[agent-session] ${scope}: ${message}`, + suppressed > 0 ? { ...fields, suppressed } : fields + ) + } + return neverThrowingStructuredAgentSessionLogger({ warn: write('warn'), error: write('error') }) +} + +const MAX_LOGGED_CAUSES = 3 + +/** The span's failure carries an Error's name, message and stack; its code and causes ride here. + * A value that is not an Error is written whole, as it would serialize. */ +function errorAttributes(error: unknown): Record { + if (error === undefined) { + return {} + } + if (!(error instanceof Error)) { + return { error } + } + const causes: string[] = [] + let cause: unknown = error.cause + // Name and message only: a cause's own fields can carry what the failing step was handling. + while (cause !== undefined && causes.length < MAX_LOGGED_CAUSES) { + if (cause instanceof Error) { + causes.push(`${cause.name}: ${cause.message}${codeSuffix(cause)}`) + cause = cause.cause + } else { + causes.push(typeof cause === 'object' && cause !== null ? '[non-error cause]' : String(cause)) + cause = undefined + } + } + const code = errorCode(error) + // node:sqlite's `code` is one generic value; `errcode` tells SQLITE_BUSY from SQLITE_FULL. + const sqliteCode = codeValue('errcode' in error ? error.errcode : undefined) + // A refusal's message is its bare code; its reason is wire-safe and tells refusals apart. + const details = isAgentSessionRefusalError(error) ? error.refusal.details : undefined + const reason = details && 'reason' in details ? details.reason : undefined + return { + ...(code !== undefined ? { errorCode: code } : {}), + ...(sqliteCode !== undefined ? { errorErrcode: sqliteCode } : {}), + ...(typeof reason === 'string' ? { refusalReason: reason } : {}), + ...(causes.length > 0 ? { errorCause: causes } : {}) + } +} + +/** The same value always renders the same: object keys sorted, and no throw on a cycle. */ +function stableJson(value: unknown): string { + try { + return JSON.stringify(value, (_key, inner: unknown) => { + if (typeof inner === 'bigint') { + return String(inner) + } + if (typeof inner !== 'object' || inner === null || Array.isArray(inner)) { + return inner + } + return Object.fromEntries( + Object.entries(inner).sort(([left], [right]) => (left < right ? -1 : 1)) + ) + }) + } catch { + return String(value) + } +} + +function errorCode(error: Error): string | number | undefined { + return codeValue('code' in error ? error.code : undefined) +} + +function codeValue(value: unknown): string | number | undefined { + return typeof value === 'string' || typeof value === 'number' ? value : undefined +} + +function codeSuffix(error: Error): string { + const code = errorCode(error) + return code !== undefined && !error.message.includes(String(code)) ? ` [${code}]` : '' +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts index aa4a9547001..9ab31743cf4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts @@ -37,6 +37,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const PROVIDER_ROW = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1' } @@ -62,6 +63,7 @@ beforeEach(async () => { dispatch = vi.fn(async () => ({ state: 'admitted' as const })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire: async (input) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 3e96a02ce26..6199531c8ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -38,6 +38,7 @@ import type { MutationPlan } from './structured-agent-session-mutation-plans' import { runSettledAgentSessionMutation } from './structured-agent-session-operation-settlement' import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' // The code is shared with the client so a read that refuses this way can be told apart from a // transcript that failed to load; the two must never drift apart. @@ -61,6 +62,7 @@ export type AgentSessionMutationSessionPreparation = export type AgentSessionMutationRequest = { store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter + logger: StructuredAgentSessionLogger callerKey: string envelope: AgentSessionMutationEnvelope plan: MutationPlan @@ -127,7 +129,7 @@ export async function admitAndRunAgentSessionMutation( admitted = await request.store.admitMutationOperation(operation) } catch (error) { if (plan.runsWithoutLedgerRow) { - admitted = admitWithoutLedgerRow(request.store, operation, error) + admitted = admitWithoutLedgerRow(request, operation, error) ledgerRowWritten = false } else if ( isAgentSessionRefusalError(error) || @@ -198,13 +200,14 @@ export async function admitAndRunAgentSessionMutation( /** The committed ledger's admission, placing nothing: a failed commit left memory as it was. */ function admitWithoutLedgerRow( - store: AgentSessionRecordStore, + { store, logger }: Pick, 'store' | 'logger'>, operation: AgentSessionMutationOperationAdmission, error: unknown ): AgentSessionMutationOperationDecision { - console.warn("[agent-session] Stop's ledger row skipped:", { + logger.warn("writing Stop's ledger row failed; Stop runs without it", { + scope: 'stop-ledger-row', sessionId: operation.envelope.sessionId, - error: error instanceof Error ? error.message : String(error) + error }) const evaluated = store.evaluateMutationOperation(operation) if (!evaluated) { @@ -231,6 +234,7 @@ function turnContext( journal, fence, adapter: request.adapter, + logger: request.logger, ...(persistedOptions ? { persistedOptions } : {}), persistOptions: (options) => request.store diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index 73b47f1fe6e..01059d67ceb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -55,6 +55,7 @@ export function mutateStructuredAgentSession( admitAndRunAgentSessionMutation({ store: context.deps.store, adapter: context.deps.adapter, + logger: context.deps.logger, callerKey: caller.callerKey, envelope, plan, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts index 71750505fef..50c887c85df 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts @@ -17,9 +17,11 @@ import { } from './structured-agent-session-host-test-data' import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { sendPlan } from './structured-agent-session-mutation-plans' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' async function context(): Promise { return { + logger: createStructuredAgentSessionLogger(), sessionId: SESSION, journal: await journals.open({ identity: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts index e4675bfefb1..24de6ff708b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts @@ -52,14 +52,23 @@ export async function runSettledAgentSessionMutation(input: { if (input.plan.markUnknownBeforeRun && error instanceof AgentSessionPreDispatchError) { throw error } + const { logger, sessionId } = input.context try { await settle({ status: 'unknown' }) } catch { // Bookkeeping must not replace the operation's proof of whether dispatch began. - console.warn('[structured-agent-session] operation uncertainty persistence failed') + logger.warn('recording an operation as unknown failed', { + scope: 'operation-unknown-settlement', + sessionId, + operationId: input.envelope.clientOperationId + }) } if (outcome && !outcome.ok) { - console.warn('[structured-agent-session] refused operation settlement failed') + logger.warn('recording a refused operation failed', { + scope: 'operation-refused-settlement', + sessionId, + operationId: input.envelope.clientOperationId + }) return outcome } throw error diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts index 7700a616b38..47aecdaa00c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts @@ -6,14 +6,16 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { ensureStructuredAgentSessionAgentForOperation } from './structured-agent-session-agent-start' import { recordStructuredAgentSessionOptionIntent } from './structured-agent-session-options-read' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const SESSION = 'session-1' describe('an operation whose agent start throws', () => { it('is refused as a failed restart, with the error only in the log', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const log = recordingStructuredAgentSessionLogger() const cause = new Error('EACCES: permission denied, open /Users/me/.orca/leases.json') const context = { + deps: { logger: log.logger }, sessions: new Map(), reconcileLeases: () => Promise.reject(cause) } @@ -31,8 +33,9 @@ describe('an operation whose agent start throws', () => { message: "The agent couldn't restart." } }) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('starting the agent'), cause) - warn.mockRestore() + expect(log.entries.map((entry) => entry.fields)).toEqual([ + { scope: 'operation-agent-start', sessionId: SESSION, error: cause } + ]) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index aa7b8a17295..a544e9e1761 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -21,6 +21,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const DEFAULT_MODEL = 'gpt-default' @@ -124,6 +125,7 @@ beforeEach(async () => { store = await openTestAgentSessionRecordStore(root) router = adapter() host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: router, journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 106998ccfed..e17d5ddf074 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -117,7 +117,7 @@ export async function readStructuredAgentSessionOptions( const { adapter, store } = context.deps const live = await context.serialize(sessionId, async () => { const session = await context.openConversation(sessionId).catch((error: unknown) => { - throw journalOpenReadRefusal(error) + throw journalOpenReadRefusal(error, context.deps.logger, sessionId) }) const child = session?.child if (!child) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index 24e9dc66f44..8b2586a9ccb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -32,6 +32,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const SWEEP_MS = 5 @@ -93,6 +94,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index db9406785f8..1e171e64199 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -17,6 +17,7 @@ import { import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { performAttach } from './structured-agent-session-attach-flow' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const NOW = 1_800_000_000_000 const SESSION = 'session-alpha' @@ -82,6 +83,7 @@ async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { const input = { store, adapter, + logger: createStructuredAgentSessionLogger(), journalDatabase: openTestJournalHostDatabase(root), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root)), authority: { @@ -157,8 +159,11 @@ describe('a create that fails before any process spawns', () => { } // What failed is kept for the log. expect(warn).toHaveBeenCalledWith( - '[agent-session] provider start failed:', - expect.objectContaining({ message: raw }) + '[agent-session] provider-start: starting the provider failed', + expect.objectContaining({ + scope: 'provider-start', + error: expect.objectContaining({ message: raw }) + }) ) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index 9df12ea96fd..c45ceac238c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -15,6 +15,7 @@ import { import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { performAttach } from './structured-agent-session-attach-flow' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const NOW = 1_800_000_000_000 const SESSION = 'session-alpha' @@ -82,6 +83,7 @@ describe('processless structured session reservation', () => { await expect( performAttach({ + logger: createStructuredAgentSessionLogger(), store, adapter, openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), @@ -126,6 +128,7 @@ describe('processless structured session reservation', () => { })) } as unknown as StructuredAgentSessionAdapter const input = { + logger: createStructuredAgentSessionLogger(), store, adapter, journalDatabase: openTestJournalHostDatabase(root), @@ -163,6 +166,7 @@ describe('processless structured session reservation', () => { const acquire = vi.fn() const adapter = { supportsCreate, acquire } as unknown as StructuredAgentSessionAdapter const input = { + logger: createStructuredAgentSessionLogger(), store, adapter, journalDatabase: openTestJournalHostDatabase(root), @@ -215,6 +219,7 @@ describe('processless structured session reservation', () => { await expect( performAttach({ + logger: createStructuredAgentSessionLogger(), store, adapter, openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), @@ -280,6 +285,7 @@ describe('processless structured session reservation', () => { releaseAcquisition: vi.fn(async () => true) } as unknown as StructuredAgentSessionAdapter const input = { + logger: createStructuredAgentSessionLogger(), store, adapter, journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts index 1dd8fb73416..8d67ba0aa7c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -11,6 +11,7 @@ import { type StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' import type { AgentSessionPromptCancelRoute } from './structured-agent-session-adapter-stop' @@ -81,6 +82,7 @@ function context( flushStreamedEvents: () => Promise ): AgentSessionTurnContext { return { + logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', journal, fence: 1, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 230709e5d21..e387d2a5e99 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -40,6 +40,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { @@ -83,6 +84,7 @@ const spawnStartingChild: StructuredAgentSessionAdapter['acquire'] = async (inpu function startHost(): void { host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index 411e148c586..de2951a6292 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -16,6 +16,7 @@ import { import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CLAUDE_SESSION = 'claude-session' const hosts: StructuredAgentSessionHost[] = [] @@ -56,6 +57,7 @@ function createHost( probeOwner?: StructuredAgentSessionHost['deps']['probeOwner'] ): StructuredAgentSessionHost { const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: claudeAdapter(), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index f5e0e14b09f..2922c3fdb2e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -23,6 +23,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const INIT_DELAY_MS = 40 @@ -75,6 +76,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, // The production router is what declares create support; the bare adapter only knows locations. adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index 8f3c0390aaa..acc2e6488c3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts @@ -24,7 +24,6 @@ export type StructuredAgentSessionProviderStartedContext = { serialize: (sessionId: string, task: () => Promise) => Promise now: () => number publishStatus?: (sessionId: string) => void - onBarrierError: (sessionId: string, error: unknown) => void } export function settleStructuredAgentSessionProviderStarted( @@ -46,7 +45,11 @@ export function settleStructuredAgentSessionProviderStarted( try { await persistStartedOptions(context, event) } catch (error) { - context.onBarrierError(event.sessionId, error) + context.deps.logger.warn('recording what a started provider reported failed', { + scope: 'provider-started-options', + sessionId: event.sessionId, + error + }) } finally { context.publishStatus?.(event.sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index d63af3ba7d2..a9a1089cc1c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } @@ -54,6 +55,7 @@ export async function createQueuedMessageTestRig() { let events: StructuredAgentSessionEventSink | undefined const store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire: async ({ fence, spawnToken, events: sink }) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index b6d9d2a90be..856134f04a9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -30,6 +30,7 @@ import { structuredAgentSessionHostInstance, structuredQueuePause } from './structured-agent-session-queued-pause' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' /** Budget at accept, in the send schema's own unit (`Buffer.byteLength` of the * serialized blocks); refused readably rather than trimmed. */ @@ -263,7 +264,7 @@ export type QueuedMessageDrainDeps = { conversationFence: (sessionId: string) => number /** The consumed submission is ordinary #22821 work from here on. */ wakeDelivery: (sessionId: string) => void - onError: (sessionId: string, error: unknown) => void + logger: StructuredAgentSessionLogger } /** @@ -312,7 +313,11 @@ export class StructuredAgentSessionQueuedMessageDrain { }) .catch((error: unknown) => { this.scheduled.delete(sessionId) - this.deps.onError(sessionId, error) + this.deps.logger.warn('draining queued messages failed', { + scope: 'queued-drain', + sessionId, + error + }) }) } @@ -326,7 +331,11 @@ export class StructuredAgentSessionQueuedMessageDrain { if (journal.queuedMessages.settlementOwed() || journal.queuedMessages.deliveredByEchoOwed()) { // A live per-row hook was skipped; heal now, before a draft sends, rather than at reopen. await journal.queuedMessages.settleOwed().catch((error: unknown) => { - this.deps.onError(sessionId, error) + this.deps.logger.warn('settling owed queued-message bookkeeping failed', { + scope: 'queued-settle-owed', + sessionId, + error + }) }) } const next = oldestActionableQueuedMessage(journal) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index 5b7f4bdf16c..288848a96a6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -127,9 +127,11 @@ export async function carryQueuedMessagesToClearReplacement( operationId: input.operationId }) } catch (error) { - console.warn("[agent-session] /clear's queued-draft carry skipped:", { + ctx.logger.warn("carrying queued drafts to /clear's replacement failed", { + scope: 'clear-queued-carry', sessionId: ctx.sessionId, - error: error instanceof Error ? error.message : String(error) + replacementSessionId: input.replacementSessionId, + error }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts index 1428de75fe6..f96716d0304 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts @@ -15,6 +15,7 @@ import { randomUUID } from 'node:crypto' import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { QueuePauseFact } from '../agent-session-journal/queued-message-pause-table' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' /** A per-process id, minted once per host process like the runtime's own * `runtimeId` (`orca-runtime-runtime-id.ts`); a draft written by another @@ -74,7 +75,8 @@ export function structuredQueuePause(journal: PauseJournal): AgentSessionQueuePa */ export async function retireEndedQueuePause( sessionId: string, - journal: PauseJournal + journal: PauseJournal, + logger: StructuredAgentSessionLogger ): Promise { try { const stop = journal.queuedMessages.pause() @@ -88,9 +90,10 @@ export async function retireEndedQueuePause( adoptInto: adopt ? hostInstance : null }) } catch (error) { - console.warn("[agent-session] a started turn's queue-pause retirement skipped:", { + logger.warn('retiring a queue pause a started turn ended failed', { + scope: 'queue-pause-retirement', sessionId, - error: error instanceof Error ? error.message : String(error) + error }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts index 16a9b4aeaf7..c715f5accd9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -59,8 +59,10 @@ export async function runStopWithQueuePause( } function report(ctx: AgentSessionTurnContext, step: string, error: unknown): void { - console.warn(`[agent-session] Stop's ${step} skipped:`, { + ctx.logger.warn(`Stop's ${step} failed`, { + scope: 'stop-queued-bookkeeping', sessionId: ctx.sessionId, - error: error instanceof Error ? error.message : String(error) + step, + error }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts index 2b193d5cb82..f3861f97166 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts @@ -15,6 +15,7 @@ import { resumeStructuredAgentQueue, sendQueuedStructuredAgentMessage } from './structured-agent-session-queued-mutations' +import { deferredStructuredAgentSessionLogger } from './structured-agent-session-logger' /** `sessions` are the live conversations (their `touch` is the idle sweep's activity renewal, * which the drain's schedule rides); everything else comes from the host's mutation context, @@ -33,7 +34,8 @@ export function wireStructuredAgentSessionQueuedMessages( conversationFence: (sessionId) => structuredAgentSessionConversationFence(context().deps.store, sessionId), wakeDelivery: (sessionId) => context().wakeDelivery(sessionId), - onError: (sessionId, error) => context().deps.onEventSinkError?.({ sessionId, error }) + // Read lazily, like the rest of this wiring: the host's deps are not assigned yet. + logger: deferredStructuredAgentSessionLogger(() => context().deps.logger) }) return { drain, @@ -44,7 +46,7 @@ export function wireStructuredAgentSessionQueuedMessages( sessions.touch(sessionId) const journal = sessions.get(sessionId)?.journal if (journal && !journal.isReadOnly) { - void retireEndedQueuePause(sessionId, journal) + void retireEndedQueuePause(sessionId, journal, context().deps.logger) } drain.schedule(sessionId) }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts index 83238e95d74..945f2cefbad 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts @@ -18,6 +18,7 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const SESSION_ID = 'codex_read_restore_fixture' const WORKSPACE_ID = 'repo-1::/tmp/workspace' @@ -55,7 +56,8 @@ let journalRoot: string const openDeps = () => ({ store, journalDatabase: openTestJournalHostDatabase(journalRoot), - adapter: {} + adapter: {}, + logger: recordingStructuredAgentSessionLogger().logger }) const opened: AgentSessionJournal[] = [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts index 09cab618ab9..8f110c3e69a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts @@ -15,6 +15,7 @@ vi.mock('./structured-agent-session-restart-restore', () => ({ })) import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' // The restore pool is mocked; the host database only fills the deps' shape. const stateDirectory = mkdtempSync(join(tmpdir(), 'orca-readable-restorer-')) @@ -36,7 +37,8 @@ describe('StructuredAgentSessionReadableRestorer', () => { openDeps: { store: { getRecord: () => null, listRecords: () => records }, journalDatabase: openTestJournalHostDatabase(stateDirectory), - adapter: {} + adapter: {}, + logger: recordingStructuredAgentSessionLogger().logger }, supportsRecord: () => true, reconcile: async () => true, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index 538fb0b9dbc..a277d9a3d4c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -34,6 +34,7 @@ import { import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { StructuredAgentSessionTurnCompletionFeed } from './structured-agent-session-turn-completion-feed' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const SESSION = 'recovered-turn-session' const THREAD = 'thread-1' @@ -81,6 +82,7 @@ async function sessionWithRunningTurn() { const server = new AgentHookServer() const sessions = new Map([[SESSION, indexedStatusFeedSession({ journal })]]) const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions, getRecord: () => null, now: () => clock, @@ -129,7 +131,7 @@ async function sessionWithRunningTurn() { function settleDeadGeneration( journal: AgentSessionJournal, verdict: StructuredAgentSessionTurnVerdict -): Promise { +): ReturnType { return settleStructuredAgentSessionDeadGeneration({ journal, sessionId: SESSION, @@ -160,7 +162,7 @@ describe('a turn recovery settled after its host went away', () => { async (_label, verdict, outcome, mark) => { const session = await sessionWithRunningTurn() session.recoverAt(RECOVERED) - expect(await settleDeadGeneration(session.journal, verdict)).toBe(true) + expect(await settleDeadGeneration(session.journal, verdict)).toEqual({ ok: true }) session.publish() expect(session.summaries.at(-1)).toMatchObject({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index f69fbad1031..228fa21fbc2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -22,6 +22,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -81,6 +82,7 @@ function adapter(): StructuredAgentSessionAdapter { function openHost(overrides: Partial = {}): void { host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 83082bf923a..d5899c794e3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -29,6 +29,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const METHODS = ['agentSession.setOption', 'agentSession.send'] as const @@ -88,6 +89,7 @@ async function createHarness(options: { attached?: boolean } = {}) { setOption } const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter, journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts index d3c15de933c..4b06198e373 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts @@ -12,6 +12,7 @@ import { type StructuredAgentSessionRevisionResolver } from './structured-agent-session-event-sink' import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate' +import { testEventSinkLogging } from './structured-agent-session-logger-test-support' const ROW: AgentJournalItemIdentity = { provider: 'orca', clientMessageId: 'row' } @@ -61,7 +62,7 @@ const appendSuffix = describe('resolved revisions', () => { it('reads the row as the journal holds it when each queued revision runs', async () => { const rows = new Map() - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const bytes = estimateStructuredAgentSessionItemBytes(ROW, text('abc')) for (const suffix of ['a', 'b', 'c']) { expect( @@ -81,7 +82,7 @@ describe('resolved revisions', () => { it('skips a revision that resolves to nothing', async () => { const rows = new Map() - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.sink.tryReviseResolvedItem?.(1_000, () => null, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) @@ -93,7 +94,7 @@ describe('resolved revisions', () => { it('publishes a revision in the operation that writes it, within the same reservation', async () => { const rows = new Map() - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const bytes = estimateStructuredAgentSessionItemBytes(ROW, text('a')) deferred.sink.tryReviseResolvedItemAndPublish?.(bytes, appendSuffix('a'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE @@ -114,7 +115,7 @@ describe('resolved revisions', () => { it('refuses a resolved write larger than the reservation it was admitted with', async () => { const rows = new Map() - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const bytes = estimateStructuredAgentSessionItemBytes(ROW, text('a')) deferred.sink.tryReviseResolvedItem?.( bytes, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index e441a156b49..0045684ccb3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -33,6 +33,7 @@ import { } from './structured-agent-session-host-test-data' import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' export const REST_TEST_CALLER = { callerKey: 'client-1' } export const IDLE_MS = STRUCTURED_AGENT_SESSION_IDLE_MS @@ -160,6 +161,7 @@ export async function createRestTestRig( } const hostFor = (overrides: Partial) => new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts index 9ded5b54d98..155785ae35e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts @@ -13,6 +13,7 @@ import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' import { marker } from './structured-agent-session-restart-resume-test-harness' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' type Handover = { dispatchState: string; reason?: string | null } | undefined @@ -36,7 +37,7 @@ it('holds each slot from accept until handover or rejection, and frees it when t }, awaitSettlement: async () => ({ dispatchState: 'accepted' }), note: async () => undefined, - onNoteFailed: () => undefined + logger: createStructuredAgentSessionLogger() }) const sessions = Array.from({ length: 8 }, (_, index) => `session-${index}`) const outcomes = resumeStructuredAgentSessionsFromRestart( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts index 0fe36994cd7..973d7758358 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts @@ -10,6 +10,7 @@ import { startStructuredAgentSessionContinuation, type StructuredAgentSessionContinuationDeps } from './structured-agent-session-restart-continuation' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' /** The whole continuation: handed over, then its verdict. */ async function continueStructuredAgentSessionAfterRestart( @@ -45,7 +46,7 @@ function dependencies( : undefined ), note: vi.fn(async () => undefined), - onNoteFailed: vi.fn() + logger: createStructuredAgentSessionLogger() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 8038e90e1fc..27c09bc2bbe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -31,6 +31,7 @@ import { import { AgentSessionPreDispatchError } from './structured-agent-session-operation-settlement' import { restartContinuationEnvelope } from './structured-agent-session-restart-continuation-envelope' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' /** * All four dispatch states are preserved, never collapsed into transport success. @@ -71,7 +72,7 @@ export type StructuredAgentSessionContinuationHost = { sessionId: string, clientMessageId: string ) => Promise<{ value: AgentSessionSendResult } | undefined> - onNoteFailed: (sessionId: string, error: unknown) => void + logger: StructuredAgentSessionLogger now: () => number /** Whether the marker is still an offer. Asked at acceptance, inside the session lock, so the * first message accepted since the restart decides: the user's, or this continuation. */ @@ -103,7 +104,7 @@ export function restartContinuationDeps( agentSessionSendSubmission( (await host.awaitSendHandedOver(sessionId, clientMessageId))?.value ), - onNoteFailed: host.onNoteFailed, + logger: host.logger, note: restartNoteWriter(host) } } @@ -185,9 +186,9 @@ export type StructuredAgentSessionContinuationDeps = { /** Records a host-authored journal note: that this send was Orca's, not the user's, or that the * chat did not carry on. `tone` is a display hint older clients render as plain text. */ note: (sessionId: string, text: string, tone?: 'error' | 'warning') => Promise - /** Reports a note that could not be written. The note is best effort, but its failure is not - * allowed to be silent — a swallowed append is how this regressed unnoticed once already. */ - onNoteFailed: (sessionId: string, error: unknown) => void + /** Where a note that could not be written is reported. The note is best effort, but its failure + * is not allowed to be silent — a swallowed append is how this regressed unnoticed once already. */ + logger: StructuredAgentSessionLogger } /** A continuation handed to its agent, or already decided. */ @@ -232,7 +233,7 @@ export async function startStructuredAgentSessionContinuation( } async function noteOutcome( - deps: Pick, + deps: Pick, sessionId: string, result: StructuredAgentSessionContinuationOutcome ): Promise { @@ -241,8 +242,8 @@ async function noteOutcome( // carry on. Best effort — losing it must not turn a delivered continuation into a failure. try { await deps.note(sessionId, AGENT_SESSION_RESTART_CONTINUATION_NOTE) - } catch (error) { - deps.onNoteFailed(sessionId, error) + } catch { + reportNoteFailed(deps, sessionId) } } else { await noteNotContinued( @@ -260,7 +261,7 @@ async function noteOutcome( /** The chat itself carries the failure, so it survives the toast, a dismissed record and a restart, * and the user's next message is what moves past it. */ async function noteNotContinued( - deps: Pick, + deps: Pick, sessionId: string, outcome: 'refused' | 'not-connected' | 'unconfirmed' ): Promise { @@ -274,8 +275,8 @@ async function noteNotContinued( : AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE, 'error' )) - } catch (error) { - deps.onNoteFailed(sessionId, error) + } catch { + reportNoteFailed(deps, sessionId) } } @@ -295,7 +296,10 @@ async function sendContinuation( throw error } // Persistence can fail after dispatch; a thrown send is not proof of non-delivery. - console.warn('[structured-agent-session] restart continuation send failed') + deps.logger.warn('sending a restart continuation failed', { + scope: 'restart-continuation-send', + sessionId + }) return null }) if (!sent) { @@ -360,3 +364,14 @@ function verdictOf( ? { sessionId, outcome: 'continued' } : { sessionId, outcome: 'unknown' } } + +/** Without the error: a failed append can carry the chat's private recovery payload. */ +function reportNoteFailed( + deps: Pick, + sessionId: string +): void { + deps.logger.warn('writing a restart continuation note failed', { + scope: 'restart-continuation-note', + sessionId + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts index ee769b9e8c3..63722d1737c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts @@ -31,6 +31,7 @@ import { type StructuredAgentSessionResumeOutcome } from './structured-agent-session-restart-resume-runner' import { RESTART_CONTINUATION_SUPERSEDED } from './structured-agent-session-restart-continuation' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' type FailureCapsule = Pick< AgentSessionRecoveryCapsule, @@ -79,6 +80,7 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { retryable: (marker: AgentSessionResumeMarker) => boolean /** Makes the failed chats readable here, so `retryable` reads each one's journal. */ reveal: (markers: readonly AgentSessionResumeMarker[]) => Promise + logger: StructuredAgentSessionLogger now: () => number /** The capsule's single mutation lane, shared with the offer's own operations. */ enqueue: (operation: () => Promise) => Promise @@ -88,7 +90,9 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { return (await deps.capsule?.listFailed(deps.now())) ?? [] } catch { // Recovery is advisory; a malformed capsule must not make ordinary chat actions unusable. - console.warn('[structured-agent-session] reading recovery capsule failed') + deps.logger.warn('reading restart failures from the recovery capsule failed', { + scope: 'recovery-capsule-read' + }) return [] } } @@ -176,21 +180,30 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { await deps .enqueue(() => capsule.completeResume(operationId, completed, deps.now())) .catch(() => { - console.warn('[structured-agent-session] restart offer completion failed') + deps.logger.warn('completing a restart offer failed', { + scope: 'restart-offer-complete', + operationId + }) }) // Filed before the rollback so a failure the user must act on is never reopened as an offer // that would silently re-run it. await deps .enqueue(() => capsule.failResume(operationId, failures, deps.now())) .catch(() => { - console.warn('[structured-agent-session] restart failure record failed') + deps.logger.warn('recording a restart failure failed', { + scope: 'restart-failure-record', + operationId + }) }) // This only reopens rows still owned by this operation. Rows removed by completeResume stay // removed, even when the write of a later bookkeeping step fails. await deps .enqueue(() => capsule.rollbackResume(operationId, deps.now())) .catch(() => { - console.warn('[structured-agent-session] restart offer rollback failed') + deps.logger.warn('rolling back a restart offer failed', { + scope: 'restart-offer-rollback', + operationId + }) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 9bb2173f42f..94746daf3ec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -34,6 +34,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' /** Starts the agent explicitly — the attach a client's ensure makes — for a test that needs a * running child before its next step. Nothing else starts one ahead of a send. */ @@ -124,7 +125,9 @@ export async function interruptedRestart( const closeSession = vi.fn(async () => true) // The relaunch comes after the quit that recorded the offer. const clock = { now: NOW + 1 } + const log = recordingProductionStructuredAgentSessionLogger() const host = new StructuredAgentSessionHost({ + logger: log.logger, store, adapter: { ...adapter(), @@ -154,7 +157,7 @@ export async function interruptedRestart( await readFile(join(previous.root, AGENT_SESSION_RECOVERY_CAPSULE_FILE), 'utf8') ) const marker = parseAgentSessionResumeMarker(capsule.entries[0]?.marker) - return { ...hostTestState(), host, store, closeSession, marker, clock } + return { ...hostTestState(), host, store, log, closeSession, marker, clock } } export async function statusNotes(host: StructuredAgentSessionHost) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-records.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-records.ts index f9530039cd4..95c97be63a9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-records.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-records.ts @@ -3,6 +3,7 @@ import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionRestartOfferRecords = { /** Every pending offer. Read-only; nothing is spent. */ @@ -23,6 +24,7 @@ export function createStructuredAgentSessionRestartOfferRecords(deps: { readFailedMarkers: () => Promise hasSession: (sessionId: string) => boolean reveal: (sessionId: string) => Promise + logger: StructuredAgentSessionLogger now: () => number /** The capsule's single mutation lane, shared with the offer's own operations. */ enqueue: (operation: () => Promise) => Promise @@ -33,7 +35,9 @@ export function createStructuredAgentSessionRestartOfferRecords(deps: { } catch { // Recovery is advisory. A malformed capsule must not make ordinary chat actions unusable; // the durable bytes stay untouched so an explicit dismissal can remove them. - console.warn('[structured-agent-session] reading recovery capsule failed') + deps.logger.warn('reading restart offers from the recovery capsule failed', { + scope: 'recovery-capsule-read' + }) return [] } } @@ -69,7 +73,9 @@ export function createStructuredAgentSessionRestartOfferRecords(deps: { void deps .enqueue(() => capsule.forgetSuperseded(gone, deps.now())) .catch(() => { - console.warn('[structured-agent-session] pruning superseded restart offers failed') + deps.logger.warn('pruning superseded restart offers failed', { + scope: 'restart-offer-prune' + }) }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts index d36e5702d1d..b155674babf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts @@ -29,6 +29,7 @@ import { HOST_TEST_SESSION as SESSION, hostTestMessage } from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' afterEach(() => vi.restoreAllMocks()) @@ -450,6 +451,7 @@ describe('reading the chat against where the offer was taken', () => { } } const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + logger: createStructuredAgentSessionLogger(), // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fact reads only the journal's cursor and submissions and the child fields given here. sessions: new Map([[SESSION, session as never]]), now: () => NOW, @@ -527,6 +529,7 @@ describe('reading the chat against where the offer was taken', () => { } } const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + logger: createStructuredAgentSessionLogger(), // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fact reads only the journal's cursor and submissions and the child fields given here. sessions: new Map([[SESSION, session as never]]), now: () => afterCrash, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts index 76d8006d9a1..4924860b05f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts @@ -13,6 +13,7 @@ import type { AgentSessionResumeMarker } from '../../../shared/agent-session-res import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { isRestartContinuationOf } from './structured-agent-session-restart-continuation-envelope' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionRestartOfferWithdrawal = ReturnType< typeof createStructuredAgentSessionRestartOfferWithdrawal @@ -26,6 +27,7 @@ export type StructuredAgentSessionRestartOfferSession = Pick< export function createStructuredAgentSessionRestartOfferWithdrawal(deps: { sessions: ReadonlyMap capsule?: Pick + logger: StructuredAgentSessionLogger now: () => number /** The capsule's single mutation lane, shared with the offer's own operations. */ enqueue: (operation: () => Promise) => Promise @@ -85,7 +87,10 @@ export function createStructuredAgentSessionRestartOfferWithdrawal(deps: { ) ) .catch(() => { - console.warn('[structured-agent-session] withdrawing a restart offer failed') + deps.logger.warn('withdrawing a restart offer failed', { + scope: 'restart-offer-withdraw', + sessionId + }) }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index 59ff6958d53..a46551b4500 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -34,6 +34,7 @@ import { } from './structured-agent-session-host-test-data' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { inspect } from 'node:util' afterEach(() => vi.useRealTimers()) @@ -53,7 +54,7 @@ it('publishes continuation attribution to the subscribed chat without another pr }) it('reports a failed attribution note without an installed error sink or private details', async () => { - const { host } = await interruptedRestart() + const { host, log } = await interruptedRestart() const append = AgentSessionJournal.prototype.appendItem const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) const write = vi.spyOn(AgentSessionJournal.prototype, 'appendItem').mockImplementation(function ( @@ -69,8 +70,11 @@ it('reports a failed attribution note without an installed error sink or private const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(result.continued).toMatchObject([{ outcome: 'continued' }]) expect(warning).toHaveBeenCalledExactlyOnceWith( - '[structured-agent-session] restart continuation attribution failed' + '[agent-session] restart-continuation-note: writing a restart continuation note failed', + { scope: 'restart-continuation-note', sessionId: SESSION } ) + expect(log.scopes()).toContain('restart-continuation-note') + expect(inspect(log.entries, { depth: 8 })).not.toContain('/private/account') } finally { write.mockRestore() warning.mockRestore() @@ -318,7 +322,7 @@ it('keeps concurrent recovery reads independent and non-destructive', async () = }) it('fails closed on corrupt recovery storage while an ordinary send still works', async () => { - const { host, root, dispatch } = await interruptedRestart() + const { host, root, dispatch, log } = await interruptedRestart() await writeFile(join(root, AGENT_SESSION_RECOVERY_CAPSULE_FILE), '{') const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) expect(await host.restartResume.list()).toEqual([]) @@ -335,9 +339,15 @@ it('fails closed on corrupt recovery storage while an ordinary send still works' await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) // list; the action's read of offers and of failures; the post-action refresh of both. The send // cannot withdraw an offer it cannot read either, and says so. - const withdrawing = '[structured-agent-session] withdrawing a restart offer failed' - await vi.waitFor(() => expect(warning).toHaveBeenLastCalledWith(withdrawing)) - expect(warning.mock.calls.filter(([message]) => message !== withdrawing)).toHaveLength(5) + await vi.waitFor(() => + expect(log.entries.at(-1)).toEqual({ + level: 'warn', + message: 'withdrawing a restart offer failed', + fields: { scope: 'restart-offer-withdraw', sessionId: SESSION } + }) + ) + // Counted before the console's repeat suppression, which prints each repeated read only once. + expect(log.scopes().filter((scope) => scope !== 'restart-offer-withdraw')).toHaveLength(5) warning.mockRestore() }) @@ -451,9 +461,12 @@ it('logs teardown capsule publication failure and still releases the provider', 'agent_session_ownership_unknown' ) expect(warning).toHaveBeenCalledWith( - '[structured-agent-session] recording recovery capsule failed' + '[agent-session] teardown-recovery-capsule: recording the recovery capsule at teardown failed', + { scope: 'teardown-recovery-capsule' } ) - expect(warning.mock.calls.flat().map(String).join(' ')).not.toContain(previous.root) + expect(inspect(warning.mock.calls, { depth: 8 })).not.toContain(previous.root) + expect(previous.log.scopes()).toContain('teardown-recovery-capsule') + expect(inspect(previous.log.entries, { depth: 8 })).not.toContain(previous.root) expect(previous.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') warning.mockRestore() await rm(capsulePath, { recursive: true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts index 41dd7913d85..29f0c9f1047 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -8,6 +8,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { classifyStoreFailure } from './structured-agent-session-attach' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' const MAX_RECONCILIATION_PASSES = 8 @@ -53,8 +54,10 @@ export type ReaderBookkeepingFailures = { clear: () => void } +/** Startup and the read carry on past a failure: the next attach or send reconciles and resolves + * recovery again before it acts. */ export function reportEachFailureOnce( - onFailure: ((failure: unknown) => void) | undefined + logger: StructuredAgentSessionLogger ): ReaderBookkeepingFailures { let reported: string | null = null return { @@ -62,15 +65,10 @@ export function reportEachFailureOnce( const key = failureKey(failure) if (key !== reported) { reported = key - try { - onFailure?.(failure) - } catch (sinkError) { - // A throwing sink must not turn the reported failure back into a failed read. - console.warn('[structured-agent-session] reporting a lease bookkeeping failure failed', { - failure, - sinkError - }) - } + logger.warn('chat lease bookkeeping for a read failed', { + scope: 'lease-reconcile', + error: failure + }) } }, clear: () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index 4da648576ed..441d4e80626 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -18,6 +18,7 @@ vi.mock('./structured-agent-session-read-restore', () => ({ })) import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' // The restore under test is mocked; the host database only fills the deps' shape. const stateDirectory = mkdtempSync(join(tmpdir(), 'orca-restart-restore-')) @@ -29,7 +30,8 @@ afterAll(() => { const NO_OPEN_DEPS = { store: { getRecord: () => null, listRecords: () => [] }, journalDatabase: openTestJournalHostDatabase(stateDirectory), - adapter: {} + adapter: {}, + logger: recordingStructuredAgentSessionLogger().logger } describe('restart journal restoration', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index af2a0970a6f..ef8481e2b1f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -44,6 +44,7 @@ import { import type { StructuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' import { createStructuredAgentSessionRestartWitnesses } from './structured-agent-session-restart-witnesses' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' type LiveSession = StructuredAgentSessionRestartOfferSession @@ -77,6 +78,7 @@ export function createStructuredAgentSessionRestartResume( store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter recoveryCapsule?: AgentSessionRecoveryCapsule + logger: StructuredAgentSessionLogger }, sessions: ReadonlyMap, surfaces: StructuredAgentSessionRestartResumeSurfaces @@ -96,6 +98,7 @@ export function createStructuredAgentSessionRestartResume( const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ sessions, ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), + logger: deps.logger, now: surfaces.now, enqueue: enqueueRecoveryOperation }) @@ -111,6 +114,7 @@ export function createStructuredAgentSessionRestartResume( adapter: deps.adapter, retryable: (marker) => derive([marker], 'may-be-held').candidates.length === 1, reveal: (markers) => revealMarkers(markers), + logger: deps.logger, now: surfaces.now, enqueue: enqueueRecoveryOperation }) @@ -123,6 +127,7 @@ export function createStructuredAgentSessionRestartResume( reveal: async (sessionId) => { await surfaces.revealSession(sessionId).catch(() => null) }, + logger: deps.logger, now: surfaces.now, enqueue: enqueueRecoveryOperation }) @@ -139,6 +144,7 @@ export function createStructuredAgentSessionRestartResume( const continuationHost: StructuredAgentSessionContinuationHost = { ...surfaces, + logger: deps.logger, sessions, conversationFence: (sessionId) => deps.store.getRecord(sessionId) @@ -206,7 +212,10 @@ export function createStructuredAgentSessionRestartResume( await enqueueRecoveryOperation(() => deps.recoveryCapsule!.rollbackResume(operationId, surfaces.now()) ).catch(() => { - console.warn('[structured-agent-session] restart offer rollback failed') + deps.logger.warn('rolling back a restart offer reservation failed', { + scope: 'restart-offer-rollback', + operationId + }) }) } throw error @@ -280,7 +289,9 @@ export function createStructuredAgentSessionRestartResume( remainingCandidates = await list() remainingFailures = await failures.list() } catch { - console.warn('[structured-agent-session] restart offer refresh failed after action') + deps.logger.warn('refreshing restart offers after an action failed', { + scope: 'restart-offer-refresh' + }) } return { resumed, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts index bb9eb500d1d..2284367c14b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts @@ -1,8 +1,7 @@ // Binds the restart-resume surface to the host's own capabilities. // // Its own file because the bindings carry real decisions — which caller key the continuation sends -// under, that the verdict comes from the settlement waiter rather than the send result, and where a -// failed journal note is reported — and those belong next to the collaborator that consumes them +// under, that the verdict comes from the settlement waiter rather than the send result, — and those belong next to the collaborator that consumes them // rather than buried in the host constructor. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' @@ -30,7 +29,6 @@ export type StructuredAgentSessionRestartResumeSurfaces = { sessionId: string, clientMessageId: string ) => Promise<{ value: AgentSessionSendResult } | undefined> - onNoteFailed: (sessionId: string, error: unknown) => void /** The session's child records, the host's one read of them. */ readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined now: () => number @@ -79,8 +77,6 @@ export function structuredAgentSessionRestartResumeSurfaces( until: 'handed-over', budgetMs: MAX_TIMER_DELAY_MS }), - onNoteFailed: () => - console.warn('[structured-agent-session] restart continuation attribution failed'), now } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts index 35425a70afe..6cde8d87b84 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts @@ -27,6 +27,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -57,6 +58,7 @@ function adapter(): StructuredAgentSessionAdapter { function createHost(store: AgentSessionRecordStore): StructuredAgentSessionHost { const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts index d611bdb1589..3d920db766a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts @@ -16,12 +16,13 @@ import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD } from './structured-agent-session-host-test-data' +import { inspect } from 'node:util' it.each(['beginTeardown', 'captureBeforeStop', 'recordMarkers'] as const)( 'keeps private %s failures out of logs while completing teardown', async (method) => { await attach() - const { host, store } = hostTestState() + const { host, store, log } = hostTestState() const failure = new Error('private recovery payload at /private/account/session.json') const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) const operation = vi.spyOn(host.restartResume, method).mockImplementation(() => { @@ -30,14 +31,27 @@ it.each(['beginTeardown', 'captureBeforeStop', 'recordMarkers'] as const)( try { await expect(host.flushAllStreamedEvents()).resolves.toBeUndefined() expect(operation).toHaveBeenCalledOnce() - expect(warning).toHaveBeenCalledExactlyOnceWith( + expect(warning).toHaveBeenCalledOnce() + expect(warning.mock.calls[0]?.[0]).toBe( { - beginTeardown: '[structured-agent-session] capturing recovery witnesses failed', - captureBeforeStop: '[structured-agent-session] capturing recovery witness failed', - recordMarkers: '[structured-agent-session] recording recovery capsule failed' + beginTeardown: + '[agent-session] teardown-recovery-witnesses: capturing recovery witnesses for teardown failed', + captureBeforeStop: + '[agent-session] recovery-witness: capturing a recovery witness before a stop failed', + recordMarkers: + '[agent-session] teardown-recovery-capsule: recording the recovery capsule at teardown failed' }[method] ) - expect(warning.mock.calls.flat().map(String).join(' ')).not.toContain(failure.message) + expect(inspect(warning.mock.calls, { depth: 8 })).not.toContain(failure.message) + // Every level and every field the logger received, not only what its console mirror printed. + expect(log.scopes()).toContain( + { + beginTeardown: 'teardown-recovery-witnesses', + captureBeforeStop: 'recovery-witness', + recordMarkers: 'teardown-recovery-capsule' + }[method] + ) + expect(inspect(log.entries, { depth: 8 })).not.toContain('/private/account') expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') await expect(host.journalSnapshot(SESSION)).rejects.toThrow('agent_session_ownership_unknown') } finally { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index 2ea475cf3ba..51ab878e2c8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -38,6 +38,8 @@ import { createStructuredAgentSessionConversationLifetime } from './structured-a import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import { createStructuredAgentSessionRestartOfferWithdrawal } from './structured-agent-session-restart-offer-withdrawal' import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const { readOnlyOpens, openReadOnly } = vi.hoisted(() => ({ readOnlyOpens: new Array(), @@ -216,7 +218,7 @@ type LifetimeHost = Parameters undefined, - onDeliveryError: () => undefined, + logger: recordingStructuredAgentSessionLogger().logger, now: () => clock }) } @@ -228,7 +230,13 @@ async function restore(sessionIds: readonly string[]) { getRecord: (sessionId: string) => recordFor(sessionId), listRecords: () => sessionIds.map(recordFor) } as unknown as AgentSessionRecordStore - const deps = { store, adapter: {}, journalDatabase: openTestJournalHostDatabase(root) } + const log = recordingStructuredAgentSessionLogger() + const deps = { + store, + adapter: {}, + journalDatabase: openTestJournalHostDatabase(root), + logger: log.logger + } await restoreStructuredAgentSessionsOnRestart({ openDeps: deps, records: sessionIds.map(recordFor), @@ -254,7 +262,7 @@ async function restore(sessionIds: readonly string[]) { readChildWork: () => undefined }) const lifetime = lifetimeOver(sessions, async (sessionId) => sessions.get(sessionId) ?? null) - return { sessions, lifetime, lifetimeOver } + return { sessions, lifetime, lifetimeOver, log } } function texts(items: readonly { body: unknown }[]): string { @@ -377,6 +385,7 @@ describe('startup restore of chats still in their per-chat files', () => { const { sessions } = await restore(['chat-a']) const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + logger: createStructuredAgentSessionLogger(), sessions, now: () => clock, enqueue: (operation) => operation() @@ -426,12 +435,11 @@ describe('startup restore of chats still in their per-chat files', () => { 'refuses a read of the chat restore %s when its copy meets damage, never with the storage text', async (reach) => { await seedLegacyChat('chat-a') - const { sessions, lifetime, lifetimeOver } = await restore(['chat-a']) + const { sessions, lifetime, lifetimeOver, log } = await restore(['chat-a']) const restored = sessions.get('chat-a')! await rm(legacyDirFor('chat-a'), { recursive: true, force: true }) await mkdir(legacyDirFor('chat-a'), { recursive: true }) await writeFile(legacyFile('chat-a'), 'not a database, and never was one') - vi.spyOn(console, 'warn').mockImplementation(() => undefined) const reader = reach === 'listed' ? lifetime : lifetimeOver(conversations(), async () => restored) @@ -442,6 +450,12 @@ describe('startup restore of chats still in their per-chat files', () => { message: 'agent_session_journal_unreadable', refusal: { details: { reason: 'journalCorrupt' } } }) + // The storage text the reader never sees goes to the host's log. + expect(log.entries.filter((entry) => entry.fields.scope === 'open-for-read')).toEqual([ + expect.objectContaining({ + fields: { scope: 'open-for-read', sessionId: 'chat-a', error: expect.any(Error) } + }) + ]) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts index 262c9e18d3c..3046aa971f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -72,7 +72,7 @@ export function createStructuredAgentSessionHostRestore( restoreReadableSessions: (sessionIds?: readonly string[]) => Promise } { const { reconcileLeases, resolveRecovery, ...rest } = wiring - const failures = reportEachFailureOnce(deps.onLeaseReconcileFailure) + const failures = reportEachFailureOnce(deps.logger) const reconcile = createReaderReconcile(reconcileLeases, failures) const restorer = new StructuredAgentSessionReadableRestorer({ openDeps: deps, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts index dd878ebe0d7..c7240eccd83 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const caller = { callerKey: 'desktop' } const KEPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'kept', ordinal: 0 } @@ -78,6 +79,7 @@ function adapter(): StructuredAgentSessionAdapter { function openHost(): StructuredAgentSessionHost { return new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(directory), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index 35e7d5af2e2..6cc37ccb724 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -29,6 +29,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const caller = { callerKey: 'desktop' } let directory: string @@ -92,6 +93,7 @@ beforeEach(async () => { closeSession: async () => true } host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter, journalDatabase: openTestJournalHostDatabase(directory), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index 6358cdfe0f0..f2a1c088e26 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -36,6 +36,7 @@ export async function rewindStructuredAgentSession( const result = await admitAndRunAgentSessionMutation({ store, adapter: context.deps.adapter, + logger: context.deps.logger, callerKey: caller.callerKey, envelope: params.envelope, // Only the provider can do this, so an agent at rest is started first. @@ -212,7 +213,7 @@ export async function rewindStructuredAgentSession( }) const journal = context.sessions.get(sessionId)!.journal await attachContext.runtimeState.flushEventSink(sessionId) - await recoverStructuredRewind(store, sessionId, journal, fence) + await recoverStructuredRewind(context.deps, sessionId, journal, fence) context.publish(sessionId, journal) return { ok: true, value: { itemId: params.itemId, epoch: journal.cursor().epoch } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts index 61696b573c0..bcfeadb6727 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts @@ -5,6 +5,7 @@ import { openConversationForWrite, structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' function withCommand(command: AgentSessionConversationCommandRecord) { return { ...agentSessionRecordFixture(), conversationCommand: command } @@ -76,9 +77,13 @@ describe('a write whose conversation the host could not open', () => { function refusedBy(error: unknown) { vi.spyOn(console, 'warn').mockImplementation(() => {}) - return openConversationForWrite(async () => { - throw error - }, ENVELOPE) + return openConversationForWrite( + async () => { + throw error + }, + ENVELOPE, + createStructuredAgentSessionLogger() + ) } it('says a corrupt history is final, in words and not the error', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts index 1925c13378e..48ceef370e7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts @@ -9,6 +9,8 @@ import { createTrackedJournalOpener } from '../agent-session-journal/journal-hos import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performSend, type AgentSessionTurnContext } from './structured-agent-session-turns' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const journals = createTrackedJournalOpener() @@ -56,6 +58,7 @@ describe('structured send idempotency', () => { const result = await performSend( { + logger: recordingStructuredAgentSessionLogger().logger, sessionId: 'session-1', journal, fence: 2, @@ -97,6 +100,7 @@ describe('structured send idempotency', () => { } })) const context: AgentSessionTurnContext = { + logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', journal, fence: 1, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts index c85c628d917..c30c33d1f43 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts @@ -25,6 +25,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ function eventually(assertion: () => unknown): Promise { @@ -70,6 +71,7 @@ async function relaunchAfterCrashMidTurn( throw new Error('claude: command not found') }) const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter(), acquire }, journalDatabase: openTestJournalHostDatabase(relaunched), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index 86873faf6b0..cf746e29b74 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -71,6 +71,10 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: { + warn: (_message, fields) => hostErrors.push(fields.error), + error: (_message, fields) => hostErrors.push(fields.error) + }, store, adapter: { acquire, @@ -84,8 +88,7 @@ beforeEach(async () => { journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - now: () => NOW, - onEventSinkError: ({ error }) => hostErrors.push(error) + now: () => NOW }) expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) }) @@ -407,8 +410,6 @@ describe('a send with no live owner', () => { acquire.mockRejectedValue( new CodexAppServerRequestError('thread/resume', -32600, `thread/resume failed: ${said}`, said) ) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const id = await accept(sendParams('after the thread went away')) // The sentence names no cause and quotes nothing; Codex's words ride in the fact for Details. @@ -426,11 +427,11 @@ describe('a send with no live owner', () => { "Codex couldn't restart. Send your message to try again." ]) // Orca's own text is logged once where the start failed. - expect(warn).toHaveBeenCalledWith( - '[agent-session] provider start failed:', - expect.objectContaining({ message: `thread/resume failed: ${said}` }) - ) - warn.mockRestore() + expect( + hostErrors.filter( + (error) => error instanceof Error && error.message === `thread/resume failed: ${said}` + ) + ).toHaveLength(1) }) it('restarts again for a Retry under a new id, and replays a resend of the same id', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index be29089991b..4b2011a02ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -24,6 +24,7 @@ import { } from './structured-agent-session-mutation-admission' import { rewindRefusal } from './structured-rewind-refusal' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' /** Why the record refuses any send right now, whoever owns it; null when a send may run. */ export function structuredAgentSessionSendBlock( @@ -56,7 +57,8 @@ export function structuredAgentSessionSendBlock( /** The conversation a send or a Stop writes to, opened when this host holds it closed. */ export async function openConversationForWrite( openConversation: (sessionId: string) => Promise, - envelope: AgentSessionMutationEnvelope + envelope: AgentSessionMutationEnvelope, + logger: StructuredAgentSessionLogger ): Promise { try { if (await openConversation(envelope.sessionId)) { @@ -64,26 +66,34 @@ export async function openConversationForWrite( } return { ok: false, refusal: AGENT_SESSION_NOT_ATTACHED } } catch (error) { - console.warn('[agent-session] opening the conversation for a write failed:', error) + logger.warn('opening the conversation for a write failed', { + scope: 'open-for-write', + sessionId: envelope.sessionId, + error + }) return { ok: false, refusal: journalOpenRefusal(error) } } } /** The conversation a write lands in, opened when this host holds it closed. */ export function openForWrite( - context: Pick, + context: Pick, envelope: AgentSessionMutationEnvelope ): () => Promise { - return () => openConversationForWrite(context.openConversation, envelope) + return () => openConversationForWrite(context.openConversation, envelope, context.deps.logger) } /** For an operation only the provider can perform: the conversation, then its agent. */ export function openWithAgent( - context: Pick, + context: Pick, envelope: AgentSessionMutationEnvelope ): () => Promise { return async () => { - const opened = await openConversationForWrite(context.openConversation, envelope) + const opened = await openConversationForWrite( + context.openConversation, + envelope, + context.deps.logger + ) return opened.ok ? context.ensureAgent(envelope.sessionId) : opened } } @@ -95,7 +105,11 @@ export function sendPreparation( envelope: AgentSessionMutationEnvelope ): () => Promise { return async () => { - const opened = await openConversationForWrite(context.openConversation, envelope) + const opened = await openConversationForWrite( + context.openConversation, + envelope, + context.deps.logger + ) const phase = context.deps.store.getRecord(envelope.sessionId)?.rewind?.phase return opened.ok && (phase === 'prepared' || phase === 'provider-succeeded') ? context.ensureAgent(envelope.sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index 21e50e4dca6..8c5e609b971 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -29,6 +29,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -146,6 +147,7 @@ beforeEach(async () => { dispatch = vi.fn(async () => ({ state: 'admitted' as const })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 17bb4b0ee1f..f14393df874 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -107,6 +108,7 @@ beforeEach(async () => { dispatch = vi.fn(async () => accepted()) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), @@ -134,6 +136,7 @@ describe('settled attach retry', () => { .mockRejectedValueOnce(new Error('journal path unavailable')) .mockResolvedValue(null) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter(), historyFilePath }, journalDatabase: openTestJournalHostDatabase(root), @@ -191,6 +194,7 @@ describe('settled attach retry', () => { }) const mintSpawnToken = vi.fn(() => 'spawn-safe') host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), @@ -235,6 +239,7 @@ describe('settled attach retry', () => { let token = 0 const mintSpawnToken = vi.fn(() => `spawn-${++token}`) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), @@ -265,6 +270,7 @@ describe('settled attach retry', () => { await host.flushAllStreamedEvents() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), @@ -322,6 +328,7 @@ describe('settled attach retry', () => { await host.flushAllStreamedEvents() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index e5527d1a5af..79227670238 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -27,6 +27,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -120,6 +121,7 @@ beforeEach(async () => { ) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire: vi.fn(async ({ fence, spawnToken }) => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index 3c4096be138..8235d0d6705 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -10,6 +10,7 @@ import { type StructuredAgentSessionUnexpectedExitContext, type StructuredAgentSessionUnexpectedExitSession } from './structured-agent-session-unexpected-exit' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const SESSION = 'session-1' const GENERATION = 'generation-1' @@ -47,6 +48,7 @@ function contextFor(session: StructuredAgentSessionUnexpectedExitSession) { }) ) const context: StructuredAgentSessionUnexpectedExitContext = { + logger: recordingStructuredAgentSessionLogger().logger, store: { getRecord: () => record, transitionHandoff: async ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts index d4f8bce0033..b71022b92c7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -81,8 +81,17 @@ async function relaunch( await seedTestAgentSessionStoreFromNewerBuild(relaunched) } const store = await openTestAgentSessionRecordStore(relaunched) - const onLeaseReconcileFailure = vi.fn() + // The lease-reconcile entries the host logs, by the failure each reports. + const leaseReconcileLogged = vi.fn() const host = new StructuredAgentSessionHost({ + logger: { + warn: (_message, fields) => { + if (fields.scope === 'lease-reconcile') { + leaseReconcileLogged(fields.error) + } + }, + error: () => undefined + }, store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(relaunched), @@ -92,30 +101,29 @@ async function relaunch( stopOwnerProcess: () => { throw new Error('an owner not proven alive must not be stopped') }, - now: () => NOW, - onLeaseReconcileFailure + now: () => NOW }) replaceHostTestState({ store, host }) - return { host, store, stateDirectory: relaunched, onLeaseReconcileFailure } + return { host, store, stateDirectory: relaunched, leaseReconcileLogged } } it('reports a startup reconcile whose store write fails, and does not reject', async () => { - const { host, store, onLeaseReconcileFailure } = await relaunch() + const { host, store, leaseReconcileLogged } = await relaunch() writes.failing = true await expect(host.reconcileRestartLeases()).resolves.toBeUndefined() - expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() - expect(onLeaseReconcileFailure).toHaveBeenCalledWith(IO_ERROR) + expect(leaseReconcileLogged).toHaveBeenCalledOnce() + expect(leaseReconcileLogged).toHaveBeenCalledWith(IO_ERROR) // Nothing was adjudicated, so the lease still grants no writer. expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(true) }) it('reconciles the chat on its next send once the store can be written again', async () => { - const { host, store, onLeaseReconcileFailure } = await relaunch() + const { host, store, leaseReconcileLogged } = await relaunch() writes.failing = true await host.reconcileRestartLeases() - expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(leaseReconcileLogged).toHaveBeenCalledOnce() writes.failing = false const body = hostTestMessage('sent after a startup reconcile failed') @@ -128,13 +136,13 @@ it('reconciles the chat on its next send once the store can be written again', a timeout: 10_000 }) expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(false) - expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(leaseReconcileLogged).toHaveBeenCalledOnce() // Before the relaunched directory is removed, so the child's wind-down can write its lease. await host.flushAllStreamedEvents() }) it('adjudicates records a newer Orca wrote in memory only, and writes nothing', async () => { - const { host, store, stateDirectory, onLeaseReconcileFailure } = await relaunch(true) + const { host, store, stateDirectory, leaseReconcileLogged } = await relaunch(true) expect(store.readOnly).toBe(true) const path = journalDatabasePath(stateDirectory) const bytes = await readFile(path) @@ -142,7 +150,7 @@ it('adjudicates records a newer Orca wrote in memory only, and writes nothing', await expect(host.reconcileRestartLeases()).resolves.toBeUndefined() - expect(onLeaseReconcileFailure).not.toHaveBeenCalled() + expect(leaseReconcileLogged).not.toHaveBeenCalled() expect(store.getRecord(SESSION)?.lease).toMatchObject({ unreconciled: false, claimStatus: 'released' @@ -152,7 +160,7 @@ it('adjudicates records a newer Orca wrote in memory only, and writes nothing', }) it('restores a chat for reading while the reconcile keeps failing, and reports it once', async () => { - const { host, store, onLeaseReconcileFailure } = await relaunch() + const { host, store, leaseReconcileLogged } = await relaunch() writes.failing = true await host.reconcileRestartLeases() @@ -160,8 +168,8 @@ it('restores a chat for reading while the reconcile keeps failing, and reports i expect(host.hasSession(SESSION)).toBe(true) expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(true) - expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() - expect(onLeaseReconcileFailure).toHaveBeenCalledWith(IO_ERROR) + expect(leaseReconcileLogged).toHaveBeenCalledOnce() + expect(leaseReconcileLogged).toHaveBeenCalledWith(IO_ERROR) }) it('refuses a send over records a newer Orca wrote with the update words', async () => { @@ -182,20 +190,20 @@ it('refuses a send over records a newer Orca wrote with the update words', async }) it('restores a chat for reading from records a newer Orca wrote', async () => { - const { host, stateDirectory, onLeaseReconcileFailure } = await relaunch(true) + const { host, stateDirectory, leaseReconcileLogged } = await relaunch(true) const path = journalDatabasePath(stateDirectory) const bytes = await readFile(path) await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() expect(host.hasSession(SESSION)).toBe(true) - expect(onLeaseReconcileFailure).not.toHaveBeenCalled() + expect(leaseReconcileLogged).not.toHaveBeenCalled() expect(await readFile(path)).toEqual(bytes) }) // A chat whose owner could not be proven gone is left recovering; the next attach or send retries it. it('restores a chat for reading when resolving its recovery cannot write the store', async () => { - const { host, store, onLeaseReconcileFailure } = await relaunch(false, async () => ({ + const { host, store, leaseReconcileLogged } = await relaunch(false, async () => ({ outcome: 'indeterminate', reason: 'probe' })) @@ -207,17 +215,17 @@ it('restores a chat for reading when resolving its recovery cannot write the sto expect(host.hasSession(SESSION)).toBe(true) expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') - expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() - expect(onLeaseReconcileFailure).toHaveBeenCalledWith(IO_ERROR) + expect(leaseReconcileLogged).toHaveBeenCalledOnce() + expect(leaseReconcileLogged).toHaveBeenCalledWith(IO_ERROR) }) it.each([ ['startup reconcile', (host: StructuredAgentSessionHost) => host.reconcileRestartLeases()], ['read restore', (host: StructuredAgentSessionHost) => host.restoreReadableSessions([SESSION])] -])('keeps the %s resolving when the failure sink throws', async (_step, read) => { - const { host, onLeaseReconcileFailure } = await relaunch() +])('keeps the %s resolving when the logger throws', async (_step, read) => { + const { host, leaseReconcileLogged } = await relaunch() const sinkError = new Error('error sink failed') - onLeaseReconcileFailure.mockImplementation(() => { + leaseReconcileLogged.mockImplementation(() => { throw sinkError }) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -225,10 +233,10 @@ it.each([ try { await expect(read(host)).resolves.toBeUndefined() - expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(leaseReconcileLogged).toHaveBeenCalledOnce() expect(warn).toHaveBeenCalledWith( - expect.stringContaining('reporting a lease bookkeeping failure failed'), - expect.objectContaining({ failure: IO_ERROR, sinkError }) + expect.stringContaining('chat lease bookkeeping for a read failed'), + expect.objectContaining({ error: IO_ERROR, loggerError: sinkError }) ) } finally { warn.mockRestore() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts index 03c490742ce..cfb801c8fd6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts @@ -10,6 +10,7 @@ import { type StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const SESSION = 'status-session' const EVIDENCE: AgentChildWorkEvidence[] = [{ type: 'session-ended', observedAt: 5 }] @@ -38,6 +39,7 @@ async function feedWith(sink: StructuredAgentSessionStatusSink) { }) const session = indexedStatusFeedSession({ journal }) return new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([[SESSION, session]]), getRecord: () => null, now: () => 1_000, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts index 545a0ebb460..c6777d8a07a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts @@ -6,6 +6,7 @@ import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const SESSION = 'status-session' const journals = createTrackedJournalOpener() @@ -42,6 +43,7 @@ it('publishes each provider child even when a replacement has the same startup p setChild({ phase: 'starting', generation: 'child-1', fence: 1 }) const events: AgentSessionStatusEvent[] = [] const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions, getRecord: () => null, now: () => 1 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts index c4e8100c30a..30ddeea9678 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts @@ -22,6 +22,7 @@ import { indexedStatusFeedSession, statusFeedChildView as childView } from './structured-agent-session-status-feed-test-session' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const SESSION = 'status-session' const USER_IDENTITY = { @@ -75,6 +76,7 @@ async function feedWithChildren(provider: AgentSessionHandleProvider = 'codex') } let now = 1_000 const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([[SESSION, indexedStatusFeedSession({ journal, provider })]]), getRecord: () => null, now: () => (now += 1), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts index c3922852245..09a3442985b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts @@ -14,6 +14,7 @@ import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wi import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const SESSION = 'clock-session' const THREAD = 'thread-1' @@ -60,6 +61,7 @@ async function openFeed() { stateDirectory: join(root, SESSION) }) const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]), getRecord: () => null, now: () => clock diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index 8062b740bcb..579be82e0c9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -23,6 +23,8 @@ import { type StructuredAgentSessionStatusFeedDeps, type StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { testEventSinkLogging } from './structured-agent-session-logger-test-support' const SESSION = 'status-session' const TURN_IDENTITY = { @@ -79,6 +81,7 @@ function feedFor( statusSink ?? (readChildWork ? { publish: () => {}, forget: () => {}, readChildWork } : undefined) const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), ...(onStatusChanged ? { onStatusChanged } : {}), ...(sink ? { statusSink: () => sink } : {}), sessions: { @@ -546,7 +549,7 @@ describe('StructuredAgentSessionStatusFeed', () => { const { feed } = feedFor(new Map([[SESSION, { journal }]]), null, (summary) => seen.push(summary.status) ) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) if (agent === 'claude') { const translator = createClaudeJournalTranslator({ sink: deferred.sink }) translator.handle({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 4f717d2c31b..ffb6767a321 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -28,6 +28,10 @@ import { type StructuredAgentSessionStatusState } from './structured-agent-session-status-journal-projection' import { structuredStatusSummariesEqual } from './structured-agent-session-status-summary-equality' +import { + deferredStructuredAgentSessionLogger, + type StructuredAgentSessionLogger +} from './structured-agent-session-logger' import { StructuredAgentSessionStatusOwnership, type StructuredAgentSessionStatusSink @@ -52,6 +56,8 @@ export type StructuredAgentSessionStatusFeedDeps = { sessions: ReadonlyMap getRecord: (sessionId: string) => AgentSessionRecord | null now: () => number + /** Where a failing sink or observer is reported; neither may cost subscribers their event. */ + logger: StructuredAgentSessionLogger /** Every projection change, whether or not anyone is subscribed. `replay` marks a re-projection * of state the host already knew (restore, an arriving subscriber) rather than a journal edge. */ onStatusChanged?: (summary: AgentSessionStatusSummary, options: { replay: boolean }) => void @@ -72,6 +78,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: { now: () => number deps: () => { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } + logger: StructuredAgentSessionLogger onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] statusSink?: StructuredAgentSessionStatusSink } @@ -82,6 +89,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: { sessions: args.sessions, getRecord: (sessionId) => args.deps().store.getRecord(sessionId), now: args.now, + logger: deferredStructuredAgentSessionLogger(() => args.deps().logger), onStatusChanged: (summary, options) => args.deps().onSessionStatusChanged?.(summary, options), // Resolved per call for the same reason the other deps are: the host builds this feed in a // field initializer, before its constructor parameters are assigned. @@ -104,6 +112,10 @@ export class StructuredAgentSessionStatusFeed { constructor(private readonly deps: StructuredAgentSessionStatusFeedDeps) {} + private logFailure(scope: string, message: string, sessionId: string, error: unknown): void { + this.deps.logger.warn(message, { scope, sessionId, error }) + } + /** Opens with every session this host has projected, live ones re-read, then only changes. */ subscribe(subscriber: StructuredAgentSessionStatusSubscriber): () => void { // Re-project before registering: a change found here has to reach the subscribers that @@ -131,7 +143,7 @@ export class StructuredAgentSessionStatusFeed { try { this.ownership.forget(sessionId) } catch (error) { - console.warn('[structured-session-status] status sink forget failed', error) + this.logFailure('status-sink-forget', 'status sink forget failed', sessionId, error) } const previous = this.published.get(sessionId) if (!previous || (!previous.children && !previous.backgroundTasks)) { @@ -215,7 +227,7 @@ export class StructuredAgentSessionStatusFeed { this.deps.onStatusChanged?.(summary, { replay: options?.replay === true }) } catch (error) { // An observer must never cost the subscribers their status event. - console.warn('[structured-session-status] status observer failed', error) + this.logFailure('status-observer', 'status observer failed', sessionId, error) } } @@ -315,7 +327,7 @@ export class StructuredAgentSessionStatusFeed { try { return this.ownership.readChildWork(sessionId) } catch (error) { - console.warn('[structured-session-status] child work read failed', error) + this.logFailure('child-work-read', 'child work read failed', sessionId, error) return undefined } } @@ -328,13 +340,13 @@ export class StructuredAgentSessionStatusFeed { try { this.ownership.publishChildWork(sessionId, evidence, session.params.provider) } catch (error) { - console.warn('[structured-session-status] child work publish failed', error) + this.logFailure('child-work-publish', 'child work publish failed', sessionId, error) return false } try { this.deps.onChildWorkChanged?.(sessionId) } catch (error) { - console.warn('[structured-session-status] child work observer failed', error) + this.logFailure('child-work-observer', 'child work observer failed', sessionId, error) } return true } @@ -347,7 +359,7 @@ export class StructuredAgentSessionStatusFeed { try { this.ownership.publish(summary, location) } catch (error) { - console.warn('[structured-session-status] status sink publish failed', error) + this.logFailure('status-sink-publish', 'status sink publish failed', summary.sessionId, error) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts index fff0ae2383b..aea5e93168a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts @@ -13,6 +13,7 @@ import { StructuredAgentSessionStatusFeed, type StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const SESSION = 'reenter-session' const journals = createTrackedJournalOpener() @@ -62,6 +63,7 @@ async function createFeed() { forget: vi.fn((subject) => server.dropStructuredStatus(subject)) } const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions, getRecord: () => null, now: () => 1_000, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts index dafe54e1790..d47efcd4cad 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts @@ -20,6 +20,8 @@ import { createTrackedJournalOpener } from '../agent-session-journal/journal-hos import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { testEventSinkLogging } from './structured-agent-session-logger-test-support' const SESSION = 'recency-session' const CODEX_THREAD = 'thread-parent' @@ -60,6 +62,7 @@ async function openSession() { // The host's status row and child records, which the feed writes into and reads back. const server = new AgentHookServer() const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([ [ SESSION, @@ -81,7 +84,7 @@ async function openSession() { }) const events: AgentSessionStatusEvent[] = [] feed.subscribe({ id: 'list-1', emit: (event) => events.push(event) }) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind({ journal, fence: 1, publish: () => feed.publish(SESSION, journal) }) const drain = async (): Promise => { expect(await deferred.drained()).toEqual({ ok: true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index cf31d1d47f1..3881e8c429f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -23,6 +23,7 @@ import { import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { MAX_RETAINED_SESSION_ACTIVITIES } from './structured-agent-session-activity-retention' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const SESSION = 'subscriber-session' @@ -227,6 +228,7 @@ describe('AgentSessionSubscribers', () => { stateDirectory: join(root, 'unread-journal') }) const statusFeed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([ [ SESSION, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 871c05a6e33..6ef96b7f98c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -76,6 +76,10 @@ function openHost( probeOwner?: (record: AgentSessionRecord) => Promise ): void { host = new StructuredAgentSessionHost({ + logger: { + warn: (_message, fields) => hostErrors.push(fields.error), + error: (_message, fields) => hostErrors.push(fields.error) + }, store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), @@ -83,7 +87,6 @@ function openHost( mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS }, now: () => clock, - onEventSinkError: ({ error }) => hostErrors.push(error), statusSink, ...(probeOwner ? { probeOwner } : {}) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts index c9cdaa997b1..7e59f695d6d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -16,6 +16,7 @@ import { threadGoalPlan } from './structured-agent-session-thread-goal' import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -68,6 +69,7 @@ function context( flushStreamedEvents: () => Promise = async () => undefined ): AgentSessionTurnContext { return { + logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', journal, fence: 1, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 3e497c311ba..a09024ffcdd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -8,6 +8,7 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -49,6 +50,7 @@ describe('performCancel', () => { ) const cancelTurn = vi.fn(async () => ({ cancelled: true })) const ctx: AgentSessionTurnContext = { + logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', journal, fence: 1, @@ -103,6 +105,7 @@ describe('performCancel', () => { } ) const ctx: AgentSessionTurnContext = { + logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', journal, fence: 1, @@ -148,6 +151,7 @@ describe('performCancel', () => { { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const ctx: AgentSessionTurnContext = { + logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', journal, fence: 1, @@ -182,6 +186,7 @@ describe('performCancel', () => { const cancelTurn = vi.fn(async () => ({ cancelled: true })) const stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) const ctx: AgentSessionTurnContext = { + logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', journal, fence: 1, @@ -220,6 +225,7 @@ describe('performCancel', () => { const cancelTurn = vi.fn(async () => ({ cancelled: true })) const stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) const ctx: AgentSessionTurnContext = { + logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', journal, fence: 1, @@ -299,6 +305,7 @@ describe('what a conversation Stop reports when the provider stopped nothing', ( await openTurn() } const ctx: AgentSessionTurnContext = { + logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', journal, fence: 1, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 13aa9d72aea..6ee38fa1d62 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -41,6 +41,7 @@ import { isJournalWrittenByNewerOrca, journalOpenRefusal } from '../agent-session-journal/journal-open-failure' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' export { performCancel } from './structured-agent-session-turns-cancel' @@ -50,6 +51,7 @@ export type AgentSessionTurnContext = { journal: AgentSessionJournal fence: number adapter: StructuredAgentSessionAdapter + logger: StructuredAgentSessionLogger persistedOptions?: Readonly> persistOptions: (options: Readonly>) => Promise /** Opaque client identity recorded as the resolver of a prompt. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index 2cb38a46634..b2ddb882207 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -13,6 +13,7 @@ import { type StructuredAgentSessionUnexpectedExitContext, type StructuredAgentSessionUnexpectedExitSession } from './structured-agent-session-unexpected-exit' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const exitOutcome = (agent: string): string => `${agent} stopped while this response was in progress. You can continue in this conversation.` @@ -104,7 +105,9 @@ describe('provider-exit settlement', () => { } as unknown as StructuredAgentSessionHostSession await settleUnexpectedStructuredAgentSessionExit( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the exit settlement reads only these members of its context; the store double is partial. { + logger: recordingStructuredAgentSessionLogger().logger, store, sessions: new Map([[SESSION, session]]), flushLifecycle: async () => { @@ -295,6 +298,7 @@ describe('provider-exit settlement', () => { const { store } = mutableStore() const context: StructuredAgentSessionUnexpectedExitContext = { + logger: recordingStructuredAgentSessionLogger().logger, store, sessions: new Map([[SESSION, session]]), flushLifecycle: async () => { @@ -354,6 +358,7 @@ describe('provider-exit settlement', () => { const { store } = mutableStore() const context: StructuredAgentSessionUnexpectedExitContext = { + logger: recordingStructuredAgentSessionLogger().logger, store, sessions: new Map([[SESSION, session]]), flushLifecycle: async () => ({ ok: true }), @@ -406,7 +411,7 @@ describe('provider-exit settlement', () => { }) } } - const release = vi.fn() + const log = recordingStructuredAgentSessionLogger() const publishFence = vi.fn() const event = { type: 'ended' as const, @@ -424,12 +429,12 @@ describe('provider-exit settlement', () => { publishFence, serialize: async (_sessionId, task) => task(), now: () => 1, - onBarrierError: release + logger: log.logger } await settleUnexpectedStructuredAgentSessionExit(context, event) expect(session.child).toBeNull() expect(publishFence).toHaveBeenCalledTimes(1) - expect(release).toHaveBeenCalledTimes(2) + expect(log.scopes()).toEqual(['exit-lifecycle-barrier', 'exit-settlement']) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 3f758f81607..419f7005c40 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -17,6 +17,7 @@ import { unfinishedStructuredAgentSessionWorkWasInterrupted } from './structured-agent-session-dead-generation-settlement' import type { StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' type UnexpectedExitLifecycleEvent = StructuredAgentSessionEndedEvent & { cause: 'unexpected-exit' @@ -37,7 +38,7 @@ export type StructuredAgentSessionUnexpectedExitContext< publishStatus?: (sessionId: string) => void serialize: (sessionId: string, task: () => Promise) => Promise now: () => number - onBarrierError?: (sessionId: string, error: unknown) => void + logger: StructuredAgentSessionLogger } export async function settleUnexpectedStructuredAgentSessionExit< @@ -93,10 +94,10 @@ export async function settleUnexpectedStructuredAgentSessionExit< try { const barrier = await context.flushLifecycle(unexpectedEvent.sessionId) if (!barrier.ok) { - context.onBarrierError?.(unexpectedEvent.sessionId, barrier.error) + logExitFailure(context, unexpectedEvent, 'exit-lifecycle-barrier', barrier.error) } } catch (error) { - context.onBarrierError?.(unexpectedEvent.sessionId, error) + logExitFailure(context, unexpectedEvent, 'exit-lifecycle-barrier', error) } await retryUnexpectedExitSettlement({ context, @@ -136,7 +137,7 @@ export async function settleUnexpectedStructuredAgentSessionExit< exitReason: unexpectedEvent.reason.slice(0, MAX_UNEXPECTED_EXIT_REASON_CHARS) }) } catch (error) { - context.onBarrierError?.(unexpectedEvent.sessionId, error) + logExitFailure(context, unexpectedEvent, 'exit-owner-release', error) } finally { endChild() if (released) { @@ -148,7 +149,7 @@ export async function settleUnexpectedStructuredAgentSessionExit< } async function retryUnexpectedExitSettlement(input: { - context: Pick + context: Pick event: UnexpectedExitLifecycleEvent journal: DeadGenerationJournal fence: number @@ -157,8 +158,8 @@ async function retryUnexpectedExitSettlement(input: { exitedDuringStartup: boolean failureTextContext: AgentSessionFailureWordsContext showUnexpectedExitOutcome?: boolean -}): Promise { - return settleStructuredAgentSessionDeadGeneration({ +}): Promise { + const settled = await settleStructuredAgentSessionDeadGeneration({ journal: input.journal, sessionId: input.event.sessionId, fence: input.fence, @@ -170,8 +171,23 @@ async function retryUnexpectedExitSettlement(input: { failureTextContext: input.failureTextContext, ...(input.exitedDuringStartup ? { exitedDuringStartup: { generation: input.event.acquisitionGeneration } } - : {}), - onError: input.context.onBarrierError + : {}) + }) + if (!settled.ok) { + logExitFailure(input.context, input.event, 'exit-settlement', settled.error) + } +} + +function logExitFailure( + context: Pick, + event: UnexpectedExitLifecycleEvent, + scope: string, + error: unknown +): void { + context.logger.warn('settling a provider exit did not finish', { + scope, + sessionId: event.sessionId, + error }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts index 3d38c1cb38d..20c5abd595e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts @@ -25,6 +25,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ function eventually(assertion: () => unknown): Promise { @@ -84,6 +85,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 2981815c1f1..08532f63c1f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -50,6 +50,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const DEAD_OWNER: AgentSessionProcessIdentity = { @@ -121,6 +122,7 @@ async function seedStore(record: PersistedAgentSessionRecord): Promise { /** Every recorded owner in these fixtures is long gone; that is the present-time evidence. */ function openHost(overrides: Partial = {}): void { host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire, diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index 380869ed21d..8bbec6f5588 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -23,6 +23,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const caller = { callerKey: 'desktop' } let directory: string @@ -64,6 +65,7 @@ let ownerProbe: AgentSessionOwnerProbe = { outcome: 'pid-absent' } async function openHost(): Promise { store = await openTestAgentSessionRecordStore(generationRoot()) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter, journalDatabase: openTestJournalHostDatabase(generationRoot()), diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 30a4bfecf51..1df9b08c78f 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -107,6 +107,7 @@ export function runStructuredConversationCommand( return admitAndRunAgentSessionMutation({ store, adapter: context.deps.adapter, + logger: context.deps.logger, callerKey: caller.callerKey, envelope, // Starts the agent only to settle a rewind in doubt, as a send does; a /clear itself starts nothing. diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts index b572e51e5f3..a45b821e465 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts @@ -13,6 +13,9 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds' import { rewindRefusal } from './structured-rewind-refusal' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' + +type RewindRecoveryDeps = { store: AgentSessionRecordStore; logger: StructuredAgentSessionLogger } export function persistRewindRecord( store: AgentSessionRecordStore, @@ -33,7 +36,7 @@ export function persistRewindRecord( * settled refused rather than proven. Bookkeeping only: the chat is already attached either way. */ async function settleUnsupportedClaudeRewind( - store: AgentSessionRecordStore, + { store, logger }: RewindRecoveryDeps, sessionId: string, fence: number, rewind: AgentSessionRewindRecord @@ -52,7 +55,8 @@ async function settleUnsupportedClaudeRewind( outcome: { status: 'failed', code: refusal.code, rewindReason: 'unsupported' } }) } catch (error) { - console.warn('[structured-rewind] pending Claude rewind was not settled:', { + logger.warn('a pending Claude rewind was not settled', { + scope: 'rewind-unsupported-settlement', sessionId, operationId: rewind.operationId, error @@ -62,20 +66,21 @@ async function settleUnsupportedClaudeRewind( /** Recovery observes provider state; it never repeats an ambiguous native mutation. */ export async function recoverStructuredRewind( - store: AgentSessionRecordStore, + deps: RewindRecoveryDeps, sessionId: string, journal: AgentSessionJournal, fence: number, adapter?: StructuredAgentSessionAdapter, now: () => number = Date.now ): Promise { + const { store } = deps let rewind = store.getRecord(sessionId)?.rewind if (rewind?.phase !== 'provider-succeeded' && rewind?.phase !== 'prepared') { return } const target = parseAgentJournalItemKey(rewind.providerItemId ?? rewind.itemId) if (target?.provider === 'claude') { - await settleUnsupportedClaudeRewind(store, sessionId, fence, rewind) + await settleUnsupportedClaudeRewind(deps, sessionId, fence, rewind) return } if (target?.provider === 'codex' && !rewind.hydrationVerified) { diff --git a/src/main/observability/index.ts b/src/main/observability/index.ts index 74728a2d81b..f620fb9a77d 100644 --- a/src/main/observability/index.ts +++ b/src/main/observability/index.ts @@ -139,13 +139,19 @@ let consent: ObservabilityConsent | null = null /** Create the local file sink, install it as the active tracer sink, and * update module-level `sink`. */ -function installLocalSink(): void { - const localSink = createLocalFileSink({ filePath: getTraceFilePath() }) - sink = localSink - setActiveSink(localSink) +function installLocalSink(filePath: string): void { + try { + const localSink = createLocalFileSink({ filePath }) + sink = localSink + setActiveSink(localSink) + } catch (error) { + // Diagnostics are optional: a logs folder that cannot be opened must not stop the app or host. + console.warn(`[observability] tracing is off: cannot open ${filePath}`, error) + } } -export function initObservability(): ObservabilityConsent { +/** `traceFilePath` names another process's own file; the desktop's is the bundle's default. */ +export function initObservability(options?: { traceFilePath?: string }): ObservabilityConsent { const c = resolveObservabilityConsent() consent = c if (!c.localFileEnabled) { @@ -153,7 +159,7 @@ export function initObservability(): ObservabilityConsent { // tracer's active sink unset, so all spans are no-ops. return c } - installLocalSink() + installLocalSink(options?.traceFilePath ?? getTraceFilePath()) installSecurePathHardeningReporter() return c } @@ -183,6 +189,15 @@ function installSecurePathHardeningReporter(): void { }) } +/** Writes buffered lines now, for an exit that runs no shutdown. */ +export function flushObservability(): void { + try { + sink?.flush() + } catch { + // Nothing is left to report to. + } +} + export async function shutdownObservability(): Promise { setSecurePathHardeningReporter(null) // Order matters: tracer first so no new pushes arrive while the local sink diff --git a/src/main/observability/logs-directory.ts b/src/main/observability/logs-directory.ts index 20ad104af74..7b244bec43e 100644 --- a/src/main/observability/logs-directory.ts +++ b/src/main/observability/logs-directory.ts @@ -35,6 +35,11 @@ export function getTraceFilePath(): string { return join(getLogsDirectory(), 'main.trace.ndjson') } +/** orcad's own trace file, named for its process as the daemon's log is. */ +export function getOrcadTraceFilePath(): string { + return join(getLogsDirectory(), 'orcad.trace.ndjson') +} + /** NDJSON lifecycle log written by the detached daemon process. Shared here so * the daemon fork (which passes it as `--log-file`) and the bundle collector * (which reads it) agree on one path. */ diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 6c0e9bbad22..854b4dfb723 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -30,6 +30,7 @@ import { export { parseArgs } let runOrcadQuitHandlers = (): void => {} +let closeOrcadObservability = (): void => {} function createNodeAppEnvironment(): AppEnvironment { const quitHandlers: (() => void)[] = [] @@ -109,7 +110,12 @@ export async function startOrcad(options: OrcadOptions = {}): Promise startOrcadRuntime(options, registerCleanup), - () => runOrcadQuitHandlers() + () => { + runOrcadQuitHandlers() + // Last, after every quit handler, so the spans they end still reach the file. + closeOrcadObservability() + closeOrcadObservability = () => {} + } ) } @@ -122,6 +128,8 @@ async function startOrcadRuntime( const { registerHeadlessPtyRuntime, getLocalPtyProvider, getSshPtyProvider } = await import('../ipc/pty') const { getAppEnvironment } = await import('../../shared/app-environment') + const { installOrcadObservability } = await import('./orcad-observability') + closeOrcadObservability = installOrcadObservability() const { resolveAdvertisedPairingEndpoint } = await import('../runtime/pairing-endpoint') const { ServeReadinessPublisher } = await import('../server/serve-readiness') const { createOrcadProfileStateStartup } = await import('./orcad-profile-state-startup') diff --git a/src/main/orcad/orcad-observability.test.ts b/src/main/orcad/orcad-observability.test.ts new file mode 100644 index 00000000000..03b49342523 --- /dev/null +++ b/src/main/orcad/orcad-observability.test.ts @@ -0,0 +1,122 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { installOrcadObservability } from './orcad-observability' + +const CI_ENV = [ + 'CI', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'CIRCLECI', + 'TRAVIS', + 'BUILDKITE', + 'JENKINS_URL', + 'TEAMCITY_VERSION' +] + +describe('orcad trace file', () => { + let dataRoot: string + + beforeEach(() => { + dataRoot = mkdtempSync(join(tmpdir(), 'orca-orcad-observability-')) + setAppEnvironment({ + getPath: (name) => { + if (name !== 'userData') { + throw new Error(`unexpected getPath: ${name}`) + } + return dataRoot + }, + getAppPath: () => dataRoot, + getVersion: () => '0.0.0-test', + isPackaged: () => true, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + // The lane is off in CI and when diagnostics are disabled; this asserts the default host. + for (const name of [...CI_ENV, 'ORCA_DIAGNOSTICS_DISABLED']) { + vi.stubEnv(name, '') + } + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + }) + + afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + rmSync(dataRoot, { recursive: true, force: true }) + }) + + const traceRecords = (): { name: string; attributes: Record }[] => + readFileSync(join(dataRoot, 'logs', 'orcad.trace.ndjson'), 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + + const reportLateSettlement = (): void => + createStructuredAgentSessionLogger().warn('settling a late dispatch failed', { + scope: 'late-settlement', + sessionId: 'session-1', + error: new Error('disk full') + }) + + it('writes a structured chat failure to its own file under the data root, flushed by close', () => { + const close = installOrcadObservability() + + reportLateSettlement() + close() + + expect(traceRecords()).toContainEqual( + expect.objectContaining({ + name: 'agentSession.late-settlement', + attributes: expect.objectContaining({ sessionId: 'session-1' }), + exit: expect.objectContaining({ _tag: 'Failure' }) + }) + ) + expect(existsSync(join(dataRoot, 'logs', 'main.trace.ndjson'))).toBe(false) + }) + + it('flushes what is buffered when the process exits with no cleanup', () => { + const before = new Set(process.listeners('exit')) + const close = installOrcadObservability() + const onExit = process.listeners('exit').filter((listener) => !before.has(listener)) + expect(onExit).toHaveLength(1) + + reportLateSettlement() + onExit[0]?.call(process, 1) + + expect(traceRecords().map((record) => record.name)).toContain('agentSession.late-settlement') + close() + expect(process.listeners('exit')).toHaveLength(before.size) + }) + + it('still starts when the logs folder cannot be opened, with tracing off', () => { + // A regular file where the folder belongs fails the open as a read-only mount or EACCES does. + writeFileSync(join(dataRoot, 'logs'), 'not a folder') + const warn = vi.mocked(console.warn) + + const close = installOrcadObservability() + reportLateSettlement() + close() + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('[observability] tracing is off'), + expect.any(Error) + ) + expect(readFileSync(join(dataRoot, 'logs'), 'utf8')).toBe('not a folder') + }) +}) + +// Booting orcad here would need its whole runtime; the wiring is pinned the way the agent-status +// store's is, by the entry point's own text. +it('orcad installs the trace file before its runtime and closes it after every quit handler', () => { + const entry = readFileSync(join(import.meta.dirname, 'orcad-entry.ts'), 'utf8') + const install = entry.indexOf('closeOrcadObservability = installOrcadObservability()') + expect(install).toBeGreaterThan(-1) + expect(install).toBeLessThan(entry.indexOf('new OrcaRuntimeService(')) + const quit = entry.indexOf(' runOrcadQuitHandlers()\n') + expect(quit).toBeGreaterThan(-1) + expect(entry.indexOf(' closeOrcadObservability()\n', quit)).toBeGreaterThan(quit) +}) diff --git a/src/main/orcad/orcad-observability.ts b/src/main/orcad/orcad-observability.ts new file mode 100644 index 00000000000..4200180a2c9 --- /dev/null +++ b/src/main/orcad/orcad-observability.ts @@ -0,0 +1,19 @@ +// orcad's local trace file: the desktop's sink and consent gates under orcad's own data root and +// its own name (`/logs/orcad.trace.ndjson`), so a headless host's spans, its reported +// structured-chat failures among them, reach a file and not only the supervisor's stderr. + +import process from 'node:process' +import { flushObservability, initObservability, shutdownObservability } from '../observability' +import { getOrcadTraceFilePath } from '../observability/logs-directory' + +/** Installed before the runtime, so its first span lands. Returns the close, which the host runs + * last in its cleanup so every quit handler's spans land too. */ +export function installOrcadObservability(): () => void { + initObservability({ traceFilePath: getOrcadTraceFilePath() }) + // `exit` runs synchronously on process.exit and after an uncaught exception; no cleanup does. + process.once('exit', flushObservability) + return () => { + process.off('exit', flushObservability) + void shutdownObservability() + } +} diff --git a/src/main/runtime/agent-session-legacy-record-import.test.ts b/src/main/runtime/agent-session-legacy-record-import.test.ts index da09cb2257d..bf2156d0710 100644 --- a/src/main/runtime/agent-session-legacy-record-import.test.ts +++ b/src/main/runtime/agent-session-legacy-record-import.test.ts @@ -17,10 +17,10 @@ import { } from '../../shared/agent-session-record.test-fixture' import { journalPragmaNumber } from '../native-chat/agent-session-journal/journal-database' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' -import type { LegacyAgentSessionRecordImportReport } from './agent-session-legacy-record-import' import { AgentSessionRecordStore } from './agent-session-record-store' import { legacyAgentSessionStorePath } from './agent-session-record-store-file' import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const NOW = 1_800_000_000_000 const ALPHA = 'session-alpha' @@ -96,15 +96,19 @@ async function writeLegacy(file: unknown, backup?: unknown): Promise { async function install(): Promise<{ database: JournalHostDatabase store: AgentSessionRecordStore - reports: LegacyAgentSessionRecordImportReport[] + reports: unknown[] }> { - const reports: LegacyAgentSessionRecordImportReport[] = [] + const log = recordingStructuredAgentSessionLogger() const database = await openStructuredAgentSessionJournalDatabase({ + logger: log.logger, stateDirectory: root, - hostId: 'local', - onLegacyRecordImportReport: (report) => reports.push(report) + hostId: 'local' }) opened.push(database) + // Each report reaches the log as one entry under its scope, its kind as the outcome. + const reports = log.entries + .filter((entry) => entry.fields.scope === 'legacy-record-import') + .map(({ fields: { scope: _scope, outcome, ...rest } }) => ({ kind: outcome, ...rest })) return { database, store: AgentSessionRecordStore.open({ journalDatabase: database, hostId: 'local' }), diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 47199f6199f..a12fc06c479 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -27,6 +27,7 @@ import { stopStructuredAgentSessionRuntime, waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const SESSION = 'claude-integration-1' const PROVIDER_SESSION = claudeSessionIdForOrcaSession(SESSION) @@ -234,6 +235,7 @@ beforeEach(async () => { publishStructuredAgentSessionTab: vi.fn(), ensureStructuredAgentSessionHost: () => ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', diff --git a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts index 3b3510916ba..eae4b7b7e32 100644 --- a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts @@ -27,7 +27,11 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi // The restore index is bookkeeping: one that cannot be written (a newer Orca's records, a // failing disk) is reported, and the tab still closes. await host.setSessionTabVisibility(tab.sessionId, false).catch((error: unknown) => { - console.warn('[structured-agent-session] recording a closed chat tab failed', error) + host.deps.logger.warn('recording a closed chat tab failed', { + scope: 'tab-visibility-close', + sessionId: tab.sessionId, + error + }) }) } } diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index adcd47baad3..b25ce381def 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -14,6 +14,7 @@ import { compareWorktreePs } from './runtime-worktree-status-projection' import type { Repo } from '../../shared/repo-types' import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment' import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { maybeAutoRenameWorkspaceOnFirstStructuredTurn } from '../agent-hooks/first-work-structured-session-rename' import { firstWorkRenameDeps } from '../agent-hooks/first-work-rename-runtime' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' @@ -142,6 +143,8 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis stateDirectory: getProfileUserDataPath(), hostId: LOCAL_EXECUTION_HOST_ID, claimKeyId: this.agentSessionClaimSigner.keyId, + // The host's local trace file (the desktop's or orcad's own), plus the console. + logger: createStructuredAgentSessionLogger(), // Resolves folder workspaces as well as git worktrees, so a chat session // in a plain folder lands in the folder rather than failing to resolve. resolveWorkspacePath: async (workspaceId) => diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index f8845221e66..61c2e0b3605 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -125,7 +125,11 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu await host .setSessionTabVisibility(input.sessionId, true, ...(input.tabId ? [input.tabId] : [])) .catch((error: unknown) => { - console.warn('[structured-agent-session] recording an opened chat tab failed', error) + host.deps.logger.warn('recording an opened chat tab failed', { + scope: 'tab-visibility-open', + sessionId: input.sessionId, + error + }) }) } this.projectStructuredAgentSessionTab(input) diff --git a/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts b/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts index b931af57463..9936c954b5b 100644 --- a/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts +++ b/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts @@ -1,6 +1,13 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' -const installed = vi.hoisted(() => ({ deps: null as Record | null })) +const installed = vi.hoisted(() => { + const state: { + deps: Record | null + logger: StructuredAgentSessionLogger | null + } = { deps: null, logger: null } + return state +}) vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -10,14 +17,18 @@ vi.mock('electron', () => ({ })) vi.mock('./structured-agent-session-runtime', () => ({ - ensureStructuredAgentSessionHost: vi.fn(async (deps: Record) => { - installed.deps = deps - }) + ensureStructuredAgentSessionHost: vi.fn( + async (deps: Record & { logger: StructuredAgentSessionLogger }) => { + installed.deps = deps + installed.logger = deps.logger + } + ) })) import { readFileSync } from 'node:fs' import { join } from 'node:path' import { OrcaRuntimeService } from './orca-runtime' +import { _resetTracerForTests, setActiveSink } from '../observability/tracer' import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' type OrcaRuntimeDeps = NonNullable[2]> @@ -78,6 +89,11 @@ describe('every host that constructs a runtime wires the agent-status store', () }) describe('structured status sink wiring', () => { + afterEach(() => { + _resetTracerForTests() + vi.restoreAllMocks() + }) + it('hands the host the sink the runtime was constructed with', async () => { installed.deps = null const sink: StructuredAgentSessionStatusSink = { publish: vi.fn(), forget: vi.fn() } @@ -88,6 +104,32 @@ describe('structured status sink wiring', () => { expect(installed.deps?.['statusSink']).toBe(sink) }) + // The runtime class does not typecheck its own calls, so the required logger is pinned here: + // a logger that writes nowhere would pass every host test while the desktop dropped failures. + it('hands the host the trace-file logger', async () => { + installed.logger = null + const push = vi.fn() + setActiveSink({ push, flush: () => {}, close: () => {} }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const runtime = new OrcaRuntimeService() + + await runtime.ensureStructuredAgentSessionHost() + const installedLogger = (): StructuredAgentSessionLogger | null => installed.logger + installedLogger()?.warn('renewing a chat lease failed', { + scope: 'lease-renewal', + sessionId: 'session-1', + error: new Error('database is locked') + }) + + expect(push).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'agentSession.lease-renewal', + attributes: expect.objectContaining({ sessionId: 'session-1' }), + exit: expect.objectContaining({ _tag: 'Failure' }) + }) + ) + }) + it('installs without a sink when none was provided', async () => { installed.deps = null const runtime = new OrcaRuntimeService() diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts index 4d919df556e..1b591051936 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts @@ -11,15 +11,21 @@ import { commitStructuredAgentSessionLaunchPrompt } from './agent-launch-structu import { structuredAgentSessionPayloadFingerprint } from '../../../../shared/structured-agent-session-mutation' import { structuredAgentSessionSendBody } from '../../../../shared/structured-agent-session-outbox' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { recordingStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const CALLER = { callerKey: 'trusted-local:runtime' } function hostWith( send: ReturnType, - journalSnapshot: ReturnType = vi.fn(() => ({ submissions: [] })) + journalSnapshot: ReturnType = vi.fn(() => ({ submissions: [] })), + log = recordingStructuredAgentSessionLogger() ): StructuredAgentSessionHost { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements the only method this module reaches; any other would throw rather than read a wrong value. - return { send, journalSnapshot } as unknown as StructuredAgentSessionHost + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements the only members this module reaches; any other would throw rather than read a wrong value. + return { + send, + journalSnapshot, + deps: { logger: log.logger } + } as unknown as StructuredAgentSessionHost } function commit(host: StructuredAgentSessionHost | null, text = 'do the thing') { @@ -80,11 +86,15 @@ describe('committing a launch prompt', () => { }) it('claims nothing, and does not fail the launch, when no row was committed', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) + const log = recordingStructuredAgentSessionLogger() + const failure = new Error('host gone') const send = vi.fn(async () => { - throw new Error('host gone') + throw failure }) - await expect(commit(hostWith(send))).resolves.toBeNull() + await expect(commit(hostWith(send, undefined, log))).resolves.toBeNull() + expect(log.entries.map((entry) => entry.fields)).toEqual([ + { scope: 'launch-prompt', sessionId: 'sess-1', error: failure } + ]) }) it('sends nothing when there is no host or no text', async () => { diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts index 11762137ba2..00c779fffe0 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts @@ -72,7 +72,11 @@ export async function commitStructuredAgentSessionLaunchPrompt(args: { } catch { // The host may have gone away before the snapshot; the caller retains the text in that case. } - console.warn('[agent-launch] the session was created, its launch prompt was not sent', error) + args.host.deps.logger.warn("sending a created chat's launch prompt failed", { + scope: 'launch-prompt', + sessionId: args.sessionId, + error + }) return null } } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 64c7ec1c8b1..6d1ddf9f359 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -13,6 +13,7 @@ import type { RpcRequest, RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' const SESSION = 'session-adoption-replay' const THREAD = 'thread-adoption-replay' @@ -184,6 +185,7 @@ describe('committed adopting create RPC replay', () => { const store = await openTestAgentSessionRecordStore(root) const sessionAdapter = adapter() host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index 9cbad804cd5..b846be96dd2 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -287,8 +287,11 @@ describe('the accessor', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const logged = (): unknown[] => warn.mock.calls - .filter(([line]) => line === '[agent-session] opening the conversation for a read failed:') - .map(([, error]) => error) + .filter( + ([line]) => + line === '[agent-session] open-for-read: opening the conversation for a read failed' + ) + .map(([, fields]) => fields?.error) const reconnect = async (): Promise => [ ...(await call('agentSession.subscribe', { sessionId: SESSION })), ...(await call('agentSession.history', { sessionId: SESSION, direction: 'tail' })) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts new file mode 100644 index 00000000000..cf708f37491 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts @@ -0,0 +1,41 @@ +import { expect, it, vi } from 'vitest' +import type { AgentSessionAttachParams } from '../../../native-chat/agent-session-wire/structured-agent-session-attach' +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { recordingStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { commitStructuredAgentSessionCreate } from './structured-agent-session-create' + +// A chat created whose tab could not be published is answered as unconfirmed; the cause is logged. +it('logs a created chat whose tab could not be published, and answers it unconfirmed', async () => { + const log = recordingStructuredAgentSessionLogger() + const failure = new Error('tab write failed') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements the only members the commit reaches past attach. + const host = { + attach: vi.fn(async () => ({ ok: true, value: { sessionId: 'session-1' } })), + getSessionTabId: vi.fn(() => null), + deps: { logger: log.logger } + } as unknown as StructuredAgentSessionHost + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the commit reaches only the tab publication on the runtime. + const runtime = { + publishStructuredAgentSessionTab: vi.fn(async () => { + throw failure + }) + } as unknown as OrcaRuntimeService + + const result = await commitStructuredAgentSessionCreate({ + runtime, + caller: { callerKey: 'client-1' }, + prepared: { + host, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stubbed attach reads none of it. + attachParams: {} as AgentSessionAttachParams, + tab: { workspaceId: 'workspace-1', agent: 'codex' } + }, + activate: true + }) + + expect(result).toMatchObject({ ok: false, refusal: { details: { reason: 'tabUnconfirmed' } } }) + expect(log.entries.map((entry) => entry.fields)).toEqual([ + { scope: 'create-tab-publication', sessionId: 'session-1', error: failure } + ]) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index 0c3b7b08713..1d0899a014d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -142,7 +142,11 @@ export async function commitStructuredAgentSessionCreate(args: { ...(surfaceTabId ? { tabId: surfaceTabId } : {}) }) } catch (error) { - console.warn('[agent-session] create committed before tab publication failed', error) + prepared.host.deps.logger.warn('publishing the tab of a created chat failed', { + scope: 'create-tab-publication', + sessionId: result.value.sessionId, + error + }) return { ok: false, refusal: refuse( diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 45c01c2c63a..446fb107411 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -28,6 +28,7 @@ import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' const CONNECTION = 'connection-1' const CLIENT = { @@ -78,6 +79,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts index f34ecb5d6cd..17aadf32a80 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { recordingStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import { isDefinitiveAgentSessionCreateRefusal } from '../../../../shared/agent-session-definitive-refusal' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' @@ -49,7 +50,11 @@ function hostStub(): StructuredAgentSessionHost { cursor: { epoch: 'epoch-a', sequence: 0 }, value: { sessionId: SESSION, fence: 1, page: {}, unconfirmedClientMessageIds: [] } })) - return { attach } as unknown as StructuredAgentSessionHost + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create reaches only attach, and the logger a failure past it reports to. + return { + attach, + deps: { logger: recordingStructuredAgentSessionLogger().logger } + } as unknown as StructuredAgentSessionHost } const resolvedIntent = { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 85d3e2a5b4b..57606c8c456 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -19,6 +19,8 @@ import { import type { RpcRequest, RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' +import { recordingStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger-test-support' export const SESSION = 'session-alpha' export const FINGERPRINT = 'f'.repeat(64) @@ -94,6 +96,7 @@ export const STATUS_ITEMS: AgentJournalRenderItem[] = [ /** One indexed session over a journal that reads back fixed items; the projection is real. */ function statusFeed(): StructuredAgentSessionStatusFeed { return new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([ [ STATUS_SESSION, @@ -206,6 +209,8 @@ export function hostStub(): StructuredAgentSessionHost { ), unsubscribe: vi.fn() }) + // Not a call: the logger the host hands a runtime caller that reports for it. + Reflect.set(hostCalls, 'deps', { logger: recordingStructuredAgentSessionLogger().logger }) return hostCalls as unknown as StructuredAgentSessionHost } diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index f437e806899..c97672269eb 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -38,6 +38,7 @@ import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { commitStructuredAgentSessionCreate } from './structured-agent-session-create' import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' const WORKTREE = `id:${HOST_TEST_LOCATION.workspaceId}` const SOURCE_TAB = `structured-agent-session-${HOST_TEST_SESSION}` @@ -93,6 +94,7 @@ function providerAdapter(): StructuredAgentSessionAdapter { async function openHost(): Promise { store = await openTestAgentSessionRecordStore(directory) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: providerAdapter(), journalDatabase: openTestJournalHostDatabase(directory), @@ -540,8 +542,8 @@ describe('a chat tab over records a newer Orca wrote', () => { expect((await snapshot()).activeTabId).toBe(`agent-session:${HOST_TEST_SESSION}`) expect(warn).toHaveBeenCalledWith( - '[structured-agent-session] recording an opened chat tab failed', - expect.anything() + '[agent-session] tab-visibility-open: recording an opened chat tab failed', + expect.objectContaining({ scope: 'tab-visibility-open', sessionId: HOST_TEST_SESSION }) ) }) }) @@ -559,8 +561,8 @@ describe('a chat tab whose restore index cannot be written', () => { expect(created.ok ? created.value.tabId : null).toBeUndefined() expect((await snapshot()).activeTabId).toBe(`agent-session:${HOST_TEST_SESSION}`) expect(warn).toHaveBeenCalledWith( - '[structured-agent-session] recording an opened chat tab failed', - failure + '[agent-session] tab-visibility-open: recording an opened chat tab failed', + { scope: 'tab-visibility-open', sessionId: HOST_TEST_SESSION, error: failure } ) expect(store.getSessionTabId(HOST_TEST_SESSION)).toBeNull() }) diff --git a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts index 0d9180dc3e5..25379632875 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts @@ -20,6 +20,7 @@ import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-w import { indexedStatusFeedSession } from '../native-chat/agent-session-wire/structured-agent-session-status-feed-test-session' import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ @@ -76,6 +77,7 @@ async function openJournal(): Promise { function publishedSummary(journal: AgentSessionJournal): AgentSessionStatusSummary { const session = indexedStatusFeedSession({ journal }) const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([[SESSION, session]]), getRecord: () => agentSessionRecordFixture(), now: () => 1_000 diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts index 50e974f1eb5..5afd2d2e770 100644 --- a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -11,6 +11,7 @@ import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' import { AgentHookServer, _internals } from '../agent-hooks/server' import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ @@ -106,6 +107,7 @@ async function awaitingApproval() { const store = new AgentHookServer() const published: AgentSessionStatusSummary[] = [] const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions, getRecord: () => null, now: () => Date.now(), diff --git a/src/main/runtime/structured-agent-session-close.test.ts b/src/main/runtime/structured-agent-session-close.test.ts index 948bb01cce2..500a6cd1beb 100644 --- a/src/main/runtime/structured-agent-session-close.test.ts +++ b/src/main/runtime/structured-agent-session-close.test.ts @@ -9,6 +9,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const hostRef: { current: unknown } = { current: null } @@ -85,8 +86,10 @@ function installHost(options: HostOptions = {}) { throw new Error('the event sink could not be flushed') } }) + const log = recordingStructuredAgentSessionLogger() hostRef.current = { deps: { + logger: log.logger, store: { getRecord: (id: string) => (id === SESSION ? entry : null), getSessionTabId: (id: string) => { @@ -101,7 +104,7 @@ function installHost(options: HostOptions = {}) { setSessionTabVisibility, close } - return { close, setSessionTabVisibility, visible } + return { close, setSessionTabVisibility, visible, log } } describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { @@ -207,11 +210,11 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { }) it('keeps the original failure when the restore itself throws', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = installHost({ stuck: true }) + const restoreFailure = new Error('agent_session_identity_required') host.setSessionTabVisibility.mockImplementation(async (_sessionId, isVisible) => { if (isVisible) { - throw new Error('agent_session_identity_required') + throw restoreFailure } }) @@ -220,7 +223,9 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { expect(outcome.stopped).toBe(false) expect(outcome.closeAttempted).toBe(true) expect(outcome.reason).not.toContain('agent_session_identity_required') - expect(warn).toHaveBeenCalled() + expect(host.log.entries.map((entry) => entry.fields)).toEqual([ + { scope: 'close-tab-restore', sessionId: SESSION, error: restoreFailure } + ]) }) it('claims nothing when the visible-tab index cannot be read', async () => { diff --git a/src/main/runtime/structured-agent-session-close.ts b/src/main/runtime/structured-agent-session-close.ts index 3ee8f265c4d..16bf8cce71d 100644 --- a/src/main/runtime/structured-agent-session-close.ts +++ b/src/main/runtime/structured-agent-session-close.ts @@ -147,9 +147,10 @@ async function restorePersistedTabVisibility( try { await host.setSessionTabVisibility?.(sessionId, true, tabId) } catch (error) { - console.warn( - `[structured-session-close] could not restore the chat tab for ${sessionId} after a failed close`, + host.deps.logger.warn('restoring the chat tab after a failed close failed', { + scope: 'close-tab-restore', + sessionId, error - ) + }) } } diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index facd5e15b05..6545ffb2ecf 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -34,6 +34,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' // The turns a send or Stop is waiting on to open, so a test knows the wait began. const openWaits = vi.hoisted(() => { @@ -217,6 +218,7 @@ beforeEach(async () => { return connection } host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', diff --git a/src/main/runtime/structured-agent-session-dispatch-followups.ts b/src/main/runtime/structured-agent-session-dispatch-followups.ts new file mode 100644 index 00000000000..dbc5a31b0d1 --- /dev/null +++ b/src/main/runtime/structured-agent-session-dispatch-followups.ts @@ -0,0 +1,52 @@ +// What the runtime does when a provider settles a dispatch after the fact: a late answer to a send, +// or the provider going idle with sends it never answered. Both run off the request that caused +// them, so their failure has nobody to return to and is logged instead. + +import { DISPATCH_DOUBT_PROVIDER_IDLE } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' + +type DispatchFollowUpHost = Pick< + StructuredAgentSessionHost, + 'settleLateDispatch' | 'releaseUnansweredDispatches' +> + +export function createStructuredAgentSessionDispatchFollowUps(input: { + /** Null until the host is built: the adapters that call these are built first. */ + host: () => DispatchFollowUpHost | null + logger: StructuredAgentSessionLogger +}): { + onDispatchSettledLate: ( + settlement: Parameters[0] + ) => void + releaseUnansweredDispatches: (input: { sessionId: string }) => void +} { + const { logger } = input + return { + onDispatchSettledLate: (settlement) => { + void input + .host() + ?.settleLateDispatch(settlement) + .catch((error: unknown) => + logger.warn('settling a dispatch the provider answered late failed', { + scope: 'late-settlement', + sessionId: settlement.sessionId, + error + }) + ) + }, + // The provider going idle is what re-derives a doubted send; a late echo still accepts it. + releaseUnansweredDispatches: ({ sessionId }) => { + void input + .host() + ?.releaseUnansweredDispatches({ sessionId, reason: DISPATCH_DOUBT_PROVIDER_IDLE }) + .catch((error: unknown) => + logger.warn('releasing sends the idle provider never answered failed', { + scope: 'unanswered-dispatch', + sessionId, + error + }) + ) + } + } +} diff --git a/src/main/runtime/structured-agent-session-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts new file mode 100644 index 00000000000..9a8d9edb96f --- /dev/null +++ b/src/main/runtime/structured-agent-session-failure-logging.test.ts @@ -0,0 +1,340 @@ +// Failures the desktop host used to drop: it installed the runtime with no error callback, so +// each of these reached nobody. Every one now lands in the required logger under its own scope. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' +import type { StructuredAgentSessionHostDeps } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { StructuredAgentSessionHostRuntimeState } from '../native-chat/agent-session-wire/structured-agent-session-host-runtime-state' +import { + StructuredAgentSessionDeliveryLoop, + type StructuredAgentSessionDeliveryLoopDeps +} from '../native-chat/agent-session-wire/structured-agent-session-delivery-loop' +import { + StructuredAgentSessionIdleSweep, + type StructuredAgentSessionIdleSweepDeps +} from '../native-chat/agent-session-wire/structured-agent-session-idle-sweep' +import { + StructuredAgentSessionQueuedMessageDrain, + type QueuedMessageDrainDeps +} from '../native-chat/agent-session-wire/structured-agent-session-queued-messages' +import { + createStructuredAgentSessionLogger, + neverThrowingStructuredAgentSessionLogger +} from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { _resetTracerForTests, setActiveSink } from '../observability/tracer' +import { createStructuredAgentSessionDispatchFollowUps } from './structured-agent-session-dispatch-followups' +import { createStructuredAgentSessionLifecycleDelivery } from './structured-agent-session-lifecycle-delivery' +import { + STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED, + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const SESSION = 'session-1' + +function rejectingHost(error: Error) { + return { + settleLateDispatch: vi.fn(async () => { + throw error + }), + releaseUnansweredDispatches: vi.fn(async () => { + throw error + }) + } +} + +describe('failures the desktop host used to drop', () => { + it('logs a late dispatch settlement that fails', async () => { + const log = recordingStructuredAgentSessionLogger() + const error = new Error('settlement write failed') + const followUps = createStructuredAgentSessionDispatchFollowUps({ + host: () => rejectingHost(error), + logger: log.logger + }) + + followUps.onDispatchSettledLate({ + sessionId: SESSION, + clientMessageId: 'client-1', + state: 'unknown', + reason: 'provider went idle' + }) + + await vi.waitFor(() => + expect(log.entries).toEqual([ + { + level: 'warn', + message: 'settling a dispatch the provider answered late failed', + fields: { scope: 'late-settlement', sessionId: SESSION, error } + } + ]) + ) + }) + + it('logs releasing the sends an idle provider never answered when that fails', async () => { + const log = recordingStructuredAgentSessionLogger() + const error = new Error('release write failed') + const followUps = createStructuredAgentSessionDispatchFollowUps({ + host: () => rejectingHost(error), + logger: log.logger + }) + + followUps.releaseUnansweredDispatches({ sessionId: SESSION }) + + await vi.waitFor(() => + expect(log.entries).toEqual([ + expect.objectContaining({ + fields: { scope: 'unanswered-dispatch', sessionId: SESSION, error } + }) + ]) + ) + }) + + it('logs a provider start or exit the host could not take', async () => { + const log = recordingStructuredAgentSessionLogger() + const error = new Error('host refused the event') + const lifecycle = createStructuredAgentSessionLifecycleDelivery({ + handle: async () => { + throw error + }, + logger: log.logger, + drainObservedExits: async () => {} + }) + + lifecycle.deliver({ + type: 'started', + sessionId: SESSION, + fence: 1, + acquisitionGeneration: 'generation-1', + reportedOptions: { model: 'gpt-5' }, + restoreSkippedOptions: [] + }) + lifecycle.deliver({ + type: 'ended', + sessionId: SESSION, + reason: 'exited', + cause: 'unexpected-exit', + fence: 1, + acquisitionGeneration: 'generation-1' + }) + await lifecycle.drain() + + expect(log.entries.map((entry) => entry.fields)).toEqual([ + { scope: 'lifecycle-started', sessionId: SESSION, error }, + { scope: 'lifecycle-exit', sessionId: SESSION, error } + ]) + }) + + it('logs provider events the chat journal would not take', async () => { + const log = recordingStructuredAgentSessionLogger() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: minting and binding a sink reads no other host dependency. + const runtime = new StructuredAgentSessionHostRuntimeState({ + logger: log.logger + } as unknown as StructuredAgentSessionHostDeps) + const sink = runtime.eventSinkFor(SESSION) + const error = new Error('journal append failed') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sink calls only appendItem before it fails. + const journal = { + appendItem: async () => { + throw error + } + } as unknown as AgentSessionJournal + sink.bind({ journal, fence: 1, publish: () => {} }) + + sink.sink.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hi' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await expect(sink.drained()).resolves.toMatchObject({ ok: false }) + + expect(log.entries).toEqual([ + expect.objectContaining({ + level: 'error', + fields: { scope: 'journal-event-sink', sessionId: SESSION, error } + }) + ]) + }) +}) + +describe('host collaborators log with their own scope', () => { + const error = new Error('serialize refused') + const rejectingSerialize = async (): Promise => { + throw error + } + + it('the delivery loop logs a failed delivery, and a failed recording of it', async () => { + const log = recordingStructuredAgentSessionLogger() + const loop = new StructuredAgentSessionDeliveryLoop( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a step that rejects at its first serialize reads only these members. + { + serialize: rejectingSerialize, + trackStart: (start: Promise) => start, + logger: log.logger + } as unknown as StructuredAgentSessionDeliveryLoopDeps + ) + + loop.wake(SESSION) + + await vi.waitFor(() => + expect(log.entries.map((entry) => entry.fields)).toEqual([ + { scope: 'delivery-loop', sessionId: SESSION, error }, + { scope: 'delivery-loop-fail', sessionId: SESSION, error } + ]) + ) + }) + + it('the idle sweep logs a step that fails', async () => { + const log = recordingStructuredAgentSessionLogger() + const sweep = new StructuredAgentSessionIdleSweep( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a tick whose serialize rejects reads only these members. + { + sessions: new Map([[SESSION, {}]]), + isDisposed: () => false, + serialize: rejectingSerialize, + logger: log.logger + } as unknown as StructuredAgentSessionIdleSweepDeps + ) + + await sweep.tick() + + expect(log.entries.map((entry) => entry.fields)).toEqual([ + { scope: 'idle-sweep', sessionId: SESSION, error } + ]) + }) + + it('the queued-message drain logs a step that fails', async () => { + const log = recordingStructuredAgentSessionLogger() + const journal = { isReadOnly: false, queuedMessages: { settlementOwed: () => true } } + const drain = new StructuredAgentSessionQueuedMessageDrain( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a schedule past the owed-settlement check reads only these members. + { + sessions: new Map([[SESSION, { journal }]]), + serialize: rejectingSerialize, + logger: log.logger + } as unknown as QueuedMessageDrainDeps + ) + + drain.schedule(SESSION) + + await vi.waitFor(() => + expect(log.entries.map((entry) => entry.fields)).toEqual([ + { scope: 'queued-drain', sessionId: SESSION, error } + ]) + ) + }) +}) + +describe('a logger that throws', () => { + it('never throws out of the report, and still prints what failed', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const loggerError = new Error('logger broke') + const logger = neverThrowingStructuredAgentSessionLogger({ + warn: () => { + throw loggerError + }, + error: () => { + throw loggerError + } + }) + const error = new Error('what failed') + + expect(() => logger.warn('a step failed', { scope: 'step', error })).not.toThrow() + expect(() => logger.error('a step failed', { scope: 'step', error })).not.toThrow() + expect(warn).toHaveBeenCalledWith('[agent-session] a step failed', { + scope: 'step', + error, + loggerError + }) + warn.mockRestore() + }) +}) + +describe('the production logger', () => { + afterEach(() => { + _resetTracerForTests() + vi.restoreAllMocks() + }) + + it('writes each entry to the trace file as a failed span named for its scope', () => { + const push = vi.fn() + setActiveSink({ push, flush: () => {}, close: () => {} }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + createStructuredAgentSessionLogger().warn('settling a late dispatch failed', { + scope: 'late-settlement', + sessionId: SESSION, + error: new Error('disk full') + }) + + expect(push).toHaveBeenCalledOnce() + expect(push.mock.calls[0]?.[0]).toMatchObject({ + type: 'effect-span', + name: 'agentSession.late-settlement', + attributes: { + level: 'warn', + message: 'settling a late dispatch failed', + sessionId: SESSION + }, + exit: { _tag: 'Failure', cause: expect.stringContaining('Error: disk full') } + }) + }) +}) + +describe('installing the runtime', () => { + let stateDirectory: string | null = null + + afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (stateDirectory) { + await rm(stateDirectory, { recursive: true, force: true }) + stateDirectory = null + } + }) + + it('refuses without a logger rather than dropping every failure', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-logger-wiring-')) + stateDirectory = root + + await expect( + ensureStructuredAgentSessionHost( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: deliberately missing the logger, as a `@ts-nocheck` caller could drop it. + { + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + } as unknown as Parameters[0] + ) + ).rejects.toThrow(STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED) + }) + + it('hands the host a logger whose throw cannot reach the operation it reports', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-logger-wiring-')) + stateDirectory = root + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const host = await ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + logger: { + warn: () => { + throw new Error('logger broke') + }, + error: () => { + throw new Error('logger broke') + } + } + }) + + expect(() => host.deps.logger.warn('a step failed', { scope: 'step' })).not.toThrow() + }) +}) diff --git a/src/main/runtime/structured-agent-session-host-refusal.test.ts b/src/main/runtime/structured-agent-session-host-refusal.test.ts index d05c9a78422..3ef5965b4e8 100644 --- a/src/main/runtime/structured-agent-session-host-refusal.test.ts +++ b/src/main/runtime/structured-agent-session-host-refusal.test.ts @@ -25,8 +25,12 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' let root: string +let log = recordingStructuredAgentSessionLogger() +const openFailureLogs = () => + log.entries.filter((entry) => entry.fields.scope === 'journal-database-open') // An in-process caller: the same build as the host, so the gate asks it for no capability. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cleanup gate reads only `clientKind` and `clientCapabilities`. @@ -34,6 +38,7 @@ const IN_PROCESS = {} as RpcContext function install(): ReturnType { return ensureStructuredAgentSessionHost({ + logger: log.logger, stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', @@ -64,6 +69,7 @@ async function digest(path: string): Promise { beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-journal-host-refusal-')) + log = recordingStructuredAgentSessionLogger() }) afterEach(async () => { @@ -107,7 +113,6 @@ describe('a process whose journal will not open', () => { earlier.pragma('user_version = 2') earlier.close() const before = await digest(path) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) for (let attempt = 0; attempt < 3; attempt += 1) { await expect(install()).rejects.toMatchObject({ @@ -118,8 +123,8 @@ describe('a process whose journal will not open', () => { reason: 'journalCorrupt', message: 'Unable to load this chat.' }) - expect(warn).toHaveBeenCalledOnce() - const logged = String(warn.mock.calls[0]?.[1]) + expect(openFailureLogs()).toHaveLength(1) + const logged = String(openFailureLogs()[0]?.fields.error) expect(logged).toContain(path) expect(logged).toContain('unreleased development build') expect(logged).toContain('Moving the file aside') @@ -143,8 +148,6 @@ describe('a process whose journal will not open', () => { }) describe('logging a journal that will not open', () => { - const OPEN_FAILED = '[structured-agent-session] opening the chat journal database failed' - async function writeJunkJournal(): Promise { const path = journalDatabasePath(root) await rm(`${path}-wal`, { force: true }) @@ -154,8 +157,6 @@ describe('logging a journal that will not open', () => { // Every chat request retries the open; the same failure each time is one log, not one per request. it('logs a repeated failure once, with its stack, and again after an open succeeds', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const openFailureLogs = () => warn.mock.calls.filter(([message]) => message === OPEN_FAILED) await install() await stopStructuredAgentSessionRuntime() await writeJunkJournal() @@ -166,8 +167,11 @@ describe('logging a journal that will not open', () => { }) } expect(openFailureLogs()).toHaveLength(1) - expect(openFailureLogs()[0]?.[1]).toBeInstanceOf(Error) - expect(openFailureLogs()[0]?.[1]).toHaveProperty('stack', expect.stringContaining('\n')) + expect(openFailureLogs()[0]?.fields.error).toBeInstanceOf(Error) + expect(openFailureLogs()[0]?.fields.error).toHaveProperty( + 'stack', + expect.stringContaining('\n') + ) await unlink(journalDatabasePath(root)) await install() diff --git a/src/main/runtime/structured-agent-session-install-failure.test.ts b/src/main/runtime/structured-agent-session-install-failure.test.ts index 4b00cdebda6..c00e9c6890f 100644 --- a/src/main/runtime/structured-agent-session-install-failure.test.ts +++ b/src/main/runtime/structured-agent-session-install-failure.test.ts @@ -11,6 +11,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const mocks = vi.hoisted(() => ({ failWiring: vi.fn(() => false) })) @@ -32,6 +33,7 @@ let root: string function install(): ReturnType { return ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index cf41fdb1dfe..3d9e72f7404 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -43,6 +43,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const journals = createTrackedJournalOpener() @@ -330,6 +331,7 @@ beforeEach(async () => { publishStructuredAgentSessionTab: () => {}, ensureStructuredAgentSessionHost: () => ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', @@ -352,11 +354,9 @@ beforeEach(async () => { openCodexConnection: codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000 }).then(() => undefined), - registerOwnedSubscriptionCleanup: vi.fn((_id: string, dispose: () => void) => { - return { - releaseIfCurrent: dispose - } - }) + registerOwnedSubscriptionCleanup: vi.fn((_id: string, dispose: () => void) => ({ + releaseIfCurrent: dispose + })) } dispatcher = new RpcDispatcher({ runtime: runtime as unknown as OrcaRuntimeService, diff --git a/src/main/runtime/structured-agent-session-journal-open.ts b/src/main/runtime/structured-agent-session-journal-open.ts index 6dd9d964d48..54c27fbfe96 100644 --- a/src/main/runtime/structured-agent-session-journal-open.ts +++ b/src/main/runtime/structured-agent-session-journal-open.ts @@ -9,11 +9,12 @@ import { type LegacyAgentSessionRecordImportReport } from './agent-session-legacy-record-import' import { recordStructuredAgentSessionHostInstallRefusal } from './structured-agent-session-host-refusal' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' // Every chat request retries a failed open, so each distinct failure is logged once, with its stack. let lastLoggedOpenFailure: string | null = null -function logOpenFailureOnce(error: unknown): void { +function logOpenFailureOnce(logger: StructuredAgentSessionLogger, error: unknown): void { const failure = error instanceof Error ? `${'code' in error ? String(error.code) : ''}:${error.message}` @@ -22,7 +23,10 @@ function logOpenFailureOnce(error: unknown): void { return } lastLoggedOpenFailure = failure - console.warn('[structured-agent-session] opening the chat journal database failed', error) + logger.error('opening the chat journal database failed', { + scope: 'journal-database-open', + error + }) } /** The journal database. A refusal is recorded for the gate and thrown to the caller; the next @@ -30,24 +34,36 @@ function logOpenFailureOnce(error: unknown): void { export async function openStructuredAgentSessionJournalDatabase(args: { stateDirectory: string hostId: string - onLegacyRecordImportReport: (report: LegacyAgentSessionRecordImportReport) => void + logger: StructuredAgentSessionLogger }): Promise { try { const opened = await JournalHostDatabase.open(args.stateDirectory, async () => legacyAgentSessionRecordImport( await readLegacyAgentSessionRecords(args.stateDirectory, args.hostId), args.hostId, - args.onLegacyRecordImportReport + (report) => reportLegacyRecordImport(args.logger, report) ) ) recordStructuredAgentSessionHostInstallRefusal(null) lastLoggedOpenFailure = null return opened } catch (error) { - logOpenFailureOnce(error) + logOpenFailureOnce(args.logger, error) // Nothing is renamed, deleted or rebuilt: the file is left exactly as it is. const refusal = journalOpenRefusalError(error) recordStructuredAgentSessionHostInstallRefusal(refusal) throw refusal } } + +function reportLegacyRecordImport( + logger: StructuredAgentSessionLogger, + report: LegacyAgentSessionRecordImportReport +): void { + const { kind, ...fields } = report + logger.warn('importing the chat records file did not complete', { + scope: 'legacy-record-import', + outcome: kind, + ...fields + }) +} diff --git a/src/main/runtime/structured-agent-session-lifecycle-delivery.ts b/src/main/runtime/structured-agent-session-lifecycle-delivery.ts index 961c4d50aa8..ccf026de359 100644 --- a/src/main/runtime/structured-agent-session-lifecycle-delivery.ts +++ b/src/main/runtime/structured-agent-session-lifecycle-delivery.ts @@ -7,10 +7,11 @@ // serialized step, and is tracked here so the same drain still waits for it. import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' export function createStructuredAgentSessionLifecycleDelivery(input: { handle: (event: StructuredAgentSessionLifecycleEvent) => Promise | undefined - onError?: (input: { scope: string; error: unknown }) => void + logger: StructuredAgentSessionLogger /** Exits the adapter has observed but not yet published. */ drainObservedExits: () => Promise }): { @@ -24,8 +25,11 @@ export function createStructuredAgentSessionLifecycleDelivery(input: { try { await input.handle(event) } catch (error) { - const scope = event.type === 'started' ? 'started' : 'exit' - input.onError?.({ scope: `structured-agent-session-${scope}:${event.sessionId}`, error }) + input.logger.warn(`delivering a provider ${event.type} event to the host failed`, { + scope: event.type === 'started' ? 'lifecycle-started' : 'lifecycle-exit', + sessionId: event.sessionId, + error + }) } } return { diff --git a/src/main/runtime/structured-agent-session-record-import-install.test.ts b/src/main/runtime/structured-agent-session-record-import-install.test.ts index 551fab477c4..dd76d01e6c6 100644 --- a/src/main/runtime/structured-agent-session-record-import-install.test.ts +++ b/src/main/runtime/structured-agent-session-record-import-install.test.ts @@ -17,6 +17,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const NOW = 1_800_000_000_000 const IMPORTED = 'session-alpha-1' @@ -60,7 +61,7 @@ function databaseVersion(): number { } /** The runtime at startup, rooted at `root`, with its PTY daemon stubbed. */ -function startupRuntime(onError: (input: { scope: string; error: unknown }) => void) { +function startupRuntime(log: ReturnType) { const runtime = new OrcaRuntimeService() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root` and stubs the PTY daemon. const internal = runtime as unknown as { @@ -72,13 +73,13 @@ function startupRuntime(onError: (input: { scope: string; error: unknown }) => v internal.hasPersistedStructuredAgentSessionStore = () => true internal.ensureStructuredAgentSessionHost = () => ensureStructuredAgentSessionHost({ + logger: log.logger, stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), - onError + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) }) internal.refreshMobileSessionPtyRecords = async () => new Set() return { @@ -88,11 +89,11 @@ function startupRuntime(onError: (input: { scope: string; error: unknown }) => v } } -function importReports(onError: ReturnType): unknown[] { - return onError.mock.calls - .map(([input]) => input) - .filter((input) => input.scope === 'structured-agent-session-record-import') - .map((input) => input.error) +/** Each import report the install logged, its kind as the entry's outcome. */ +function importReports(log: ReturnType): unknown[] { + return log.entries + .filter((entry) => entry.fields.scope === 'legacy-record-import') + .map(({ fields: { scope: _scope, outcome, ...rest } }) => ({ kind: outcome, ...rest })) } // A read that can clear: the file's own permissions, as a locked-down or mid-restore profile has. @@ -101,13 +102,13 @@ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( async () => { await writeLegacy(legacyFile()) await chmod(legacyAgentSessionStorePath(root), 0o000) - const onError = vi.fn() - const first = startupRuntime(onError) + const log = recordingStructuredAgentSessionLogger() + const first = startupRuntime(log) await first.runtime.restoreStructuredAgentSessionTabs() const host = await first.host() - expect(importReports(onError)).toEqual([ + expect(importReports(log)).toEqual([ { kind: 'unavailable', error: expect.objectContaining({ message: 'agent_session_store_corrupt' }) @@ -142,7 +143,7 @@ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( await stopStructuredAgentSessionRuntime() await chmod(legacyAgentSessionStorePath(root), 0o600) - const second = startupRuntime(vi.fn()) + const second = startupRuntime(recordingStructuredAgentSessionLogger()) await second.runtime.restoreStructuredAgentSessionTabs() const relaunched = await second.host() @@ -156,12 +157,12 @@ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( it('installs over a file no read will make usable, reports it once, and leaves it untouched', async () => { await writeLegacy('{ truncated') - const onError = vi.fn() - const { runtime, host } = startupRuntime(onError) + const log = recordingStructuredAgentSessionLogger() + const { runtime, host } = startupRuntime(log) await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() - expect(importReports(onError)).toEqual([ + expect(importReports(log)).toEqual([ { kind: 'unusable', error: expect.objectContaining({ message: 'agent_session_store_corrupt' }) } ]) expect((await host()).legacyRecordImportOwed()).toBe(false) diff --git a/src/main/runtime/structured-agent-session-runtime-exit.test.ts b/src/main/runtime/structured-agent-session-runtime-exit.test.ts index 3da0c6f203d..2727b2389f2 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -17,6 +17,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' describe('structured session runtime provider-exit wiring', () => { let root: string | null = null @@ -80,6 +81,7 @@ describe('structured session runtime provider-exit wiring', () => { return connection }) as typeof openCodexAppServerConnection const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', @@ -184,6 +186,7 @@ describe('structured session runtime provider-exit wiring', () => { return connection }) as typeof openCodexAppServerConnection const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', @@ -214,6 +217,7 @@ describe('structured session runtime provider-exit wiring', () => { }) const restarted = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', @@ -288,6 +292,7 @@ describe('structured session runtime provider-exit wiring', () => { return connection }) as typeof openCodexAppServerConnection const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', @@ -394,6 +399,7 @@ describe('structured session runtime provider-exit wiring', () => { return connection } const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index e1d37c452a1..f572e890983 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -28,6 +28,7 @@ import { hasPersistedStructuredAgentSessionStore, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const HOST_ID = 'local' @@ -258,6 +259,7 @@ describe('structured agent-session runtime install', () => { it('holds stop until the model catalog has written its coalesced save', async () => { stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-')) await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory, hostId: HOST_ID, claimKeyId: 'key-1', @@ -297,6 +299,7 @@ describe('structured agent-session runtime install', () => { __setWindowsProcessTreeLoaderForTests(() => null) try { const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory, hostId: HOST_ID, claimKeyId: 'key-1', diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index aecf034b370..3785fdeaf5c 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -13,7 +13,6 @@ import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import { existsSync } from 'node:fs' import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { DISPATCH_DOUBT_PROVIDER_IDLE } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' import { structuredAgentSessionTeardownTrigger, @@ -53,7 +52,12 @@ import { createStructuredAgentEnvironmentResolvers } from './structured-agent-sh import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter' import { createStructuredAgentSessionLifecycleDelivery } from './structured-agent-session-lifecycle-delivery' +import { createStructuredAgentSessionDispatchFollowUps } from './structured-agent-session-dispatch-followups' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { + neverThrowingStructuredAgentSessionLogger, + type StructuredAgentSessionLogger +} from '../native-chat/agent-session-wire/structured-agent-session-logger' import { modelCatalogHostDeps, type RuntimeAgentAccountHomeResolver @@ -113,7 +117,9 @@ export type StructuredAgentSessionRuntimeDeps = { /** Which login-shell variables Codex and Claude children inherit; absent inherits all. */ resolveShellEnvironmentPolicy?: () => NativeChatShellEnvironmentPolicy resolveCodexOverrides?: () => NodeJS.ProcessEnv - onError?: (input: { scope: string; error: unknown }) => void + /** Where every failure the runtime and its host carry on past is reported. Required, so no path + * can drop one: the desktop and headless hosts both pass the trace-file logger. */ + logger: StructuredAgentSessionLogger /** Every structured-session status projection, for host-side reactions such as the first-work * workspace rename that CLI agents get from their hooks. */ onSessionStatusChanged?: StructuredAgentSessionHostDeps['onSessionStatusChanged'] @@ -132,6 +138,11 @@ let installing: Promise | null = null export const CLAUDE_STRUCTURED_AUTH_POLICY_REQUIRED = 'structured agent-session host requires a Claude auth policy resolver' +/** Thrown when the host is installed without a logger: every failure it carries on past would + * otherwise reach nobody. */ +export const STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED = + 'structured agent-session host requires a logger' + /** * Runtimes whose teardown did not finish. `installing` is cleared regardless so * nothing new attaches, but dropping the runtime as well would strand every @@ -206,16 +217,18 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise | undefined = deps.logger + if (typeof declared?.warn !== 'function' || typeof declared.error !== 'function') { + throw new Error(STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED) + } + const logger = neverThrowingStructuredAgentSessionLogger(deps.logger) const journalDatabase = await openStructuredAgentSessionJournalDatabase({ stateDirectory: deps.stateDirectory, hostId: deps.hostId, - onLegacyRecordImportReport: (report) => - deps.onError - ? deps.onError({ scope: 'structured-agent-session-record-import', error: report }) - : console.warn('[structured-agent-session] importing the chat records file', report) + logger }) try { - return await installOnJournal(deps, journalDatabase) + return await installOnJournal({ ...deps, logger }, journalDatabase) } catch (error) { // Nothing else holds the connection yet, and the next install opens its own. journalDatabase.close() @@ -233,35 +246,13 @@ async function installOnJournal( let host: StructuredAgentSessionHost | null = null const lifecycle = createStructuredAgentSessionLifecycleDelivery({ handle: (event) => host?.handleAdapterEvent(event), - ...(deps.onError ? { onError: deps.onError } : {}), + logger: deps.logger, // Claude publishes an observed exit only after its close ladder and transcript write; Codex // publishes inside its own exit callback and needs nothing. drainObservedExits: () => claude.drainObservedExits() }) - const onDispatchSettledLate = ( - settlement: Parameters[0] - ): void => { - void host?.settleLateDispatch(settlement).catch((error) => - deps.onError?.({ - scope: `structured-agent-session-late-settlement:${settlement.sessionId}`, - error - }) - ) - } - // The provider going idle is what re-derives a doubted send; a late echo still accepts it. - const releaseUnansweredDispatches = ({ sessionId }: { sessionId: string }): void => { - void host - ?.releaseUnansweredDispatches({ - sessionId, - reason: DISPATCH_DOUBT_PROVIDER_IDLE - }) - .catch((error) => - deps.onError?.({ - scope: `structured-agent-session-unanswered-dispatch:${sessionId}`, - error - }) - ) - } + const { onDispatchSettledLate, releaseUnansweredDispatches } = + createStructuredAgentSessionDispatchFollowUps({ host: () => host, logger: deps.logger }) const codex = new CodexStructuredSessionAdapter({ resolveLaunch: createCodexStructuredLaunchResolver({ store, @@ -327,12 +318,7 @@ async function installOnJournal( await deps.resolveLaunchArgs!(provider) } : {}), - onEventSinkError: ({ sessionId, error }) => - deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }), - onLeaseReconcileFailure: (error) => - deps.onError - ? deps.onError({ scope: 'structured-agent-session-lease-reconcile', error }) - : console.warn('[structured-agent-session] reconciling chat leases failed', error), + logger: deps.logger, ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), diff --git a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts index d86c54282c1..aee47ab15ba 100644 --- a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts +++ b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts @@ -17,6 +17,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' /** pid -> the NUL-separated environment block `/proc//environ` serves. */ const fakeProc = vi.hoisted(() => ({ environs: new Map() })) @@ -135,6 +136,7 @@ describe('a process that inherited a spawn token', () => { const kill = vi.spyOn(process, 'kill').mockImplementation(() => true) const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory, hostId: HOST_ID, claimKeyId: 'key-1', diff --git a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts index 32b30a5dc77..43d457790e3 100644 --- a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts +++ b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts @@ -21,6 +21,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const writes = vi.hoisted(() => ({ failing: false })) @@ -62,7 +63,7 @@ async function seedChat(options: { newer?: boolean } = {}) { return { path: journalDatabasePath(root), sessionId: record.sessionId } } -function startupRuntime(onError?: (input: { scope: string; error: unknown }) => void) { +function startupRuntime(log = recordingStructuredAgentSessionLogger()) { const runtime = new OrcaRuntimeService() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root` and stubs the PTY daemon. const internal = runtime as unknown as { @@ -73,13 +74,13 @@ function startupRuntime(onError?: (input: { scope: string; error: unknown }) => internal.hasPersistedStructuredAgentSessionStore = () => true internal.ensureStructuredAgentSessionHost = () => ensureStructuredAgentSessionHost({ + logger: log.logger, stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), - ...(onError ? { onError } : {}) + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) }) internal.refreshMobileSessionPtyRecords = async () => new Set() return runtime @@ -88,13 +89,13 @@ function startupRuntime(onError?: (input: { scope: string; error: unknown }) => it('finishes startup over records a newer Orca wrote, reads them, and writes nothing', async () => { const { path, sessionId } = await seedChat({ newer: true }) const bytes = await readFile(path) - const onError = vi.fn() - const runtime = startupRuntime(onError) + const log = recordingStructuredAgentSessionLogger() + const runtime = startupRuntime(log) // What the renderer's startup awaits through `app:prepareTerminalStartupRestoration`. await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() - expect(onError).not.toHaveBeenCalled() + expect(log.entries).toEqual([]) const host = getStructuredAgentSessionHost() expect(host?.sessionAgent(sessionId)).toBe('claude') // Adjudicated in memory only: the verdict is re-derived at the next start. @@ -105,45 +106,32 @@ it('finishes startup over records a newer Orca wrote, reads them, and writes not it('finishes startup when the reconcile cannot write, and reports it', async () => { const { sessionId } = await seedChat() - const onError = vi.fn() - const runtime = startupRuntime(onError) + const log = recordingStructuredAgentSessionLogger() + const runtime = startupRuntime(log) writes.failing = true await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() - expect(onError).toHaveBeenCalledOnce() - expect(onError).toHaveBeenCalledWith({ - scope: 'structured-agent-session-lease-reconcile', - error: expect.objectContaining({ message: 'disk I/O error' }) - }) + expect(log.entries).toEqual([ + expect.objectContaining({ + fields: { + scope: 'lease-reconcile', + error: expect.objectContaining({ message: 'disk I/O error' }) + } + }) + ]) expect(getStructuredAgentSessionHost()?.sessionAgent(sessionId)).toBe('claude') }) -// The desktop installs its host with no error sink, so the failure is logged rather than dropped. -it('logs the failure when the host has no error sink', async () => { - await seedChat() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - writes.failing = true - - await expect( - startupRuntime().prepareStructuredAgentSessionStartupRestoration() - ).resolves.toBeUndefined() - - expect(warn).toHaveBeenCalledWith( - '[structured-agent-session] reconciling chat leases failed', - expect.objectContaining({ message: 'disk I/O error' }) - ) -}) - // Closing a chat tab drops it from the restore index, which a newer Orca's records refuse: the // close is reported and goes on, since bookkeeping never keeps a tab open. it('closes a chat tab over records a newer Orca wrote, reporting the index it cannot write', async () => { const { sessionId } = await seedChat({ newer: true }) - const runtime = startupRuntime(vi.fn()) + const log = recordingStructuredAgentSessionLogger() + const runtime = startupRuntime(log) await runtime.prepareStructuredAgentSessionStartupRestoration() const host = getStructuredAgentSessionHost() const close = vi.spyOn(host!, 'close') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's own protected close path, reached with the one field it reads. const internal = runtime as unknown as { closeStructuredAgentSessionTab(tab: { sessionId: string }, cause: 'user-close'): Promise @@ -153,11 +141,16 @@ it('closes a chat tab over records a newer Orca wrote, reporting the index it ca internal.closeStructuredAgentSessionTab({ sessionId }, 'user-close') ).resolves.toBeUndefined() - expect(warn).toHaveBeenCalledWith( - '[structured-agent-session] recording a closed chat tab failed', - expect.objectContaining({ - refusal: expect.objectContaining({ details: { reason: 'journalWrittenByNewerOrca' } }) - }) - ) + expect(log.entries).toContainEqual({ + level: 'warn', + message: 'recording a closed chat tab failed', + fields: { + scope: 'tab-visibility-close', + sessionId, + error: expect.objectContaining({ + refusal: expect.objectContaining({ details: { reason: 'journalWrittenByNewerOrca' } }) + }) + } + }) expect(close).toHaveBeenCalledWith(sessionId, 'user-close') }) diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index 84c14b2e23c..ac6df4c4b2b 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -35,6 +35,8 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' // `failing` fails every record write; `grants` lets that many more through, then fails. const writes = vi.hoisted(() => ({ failing: false, grants: Infinity, refused: 0 })) @@ -134,9 +136,9 @@ async function seedProfile( }) ) const database = await openStructuredAgentSessionJournalDatabase({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, - hostId: 'local', - onLegacyRecordImportReport: () => undefined + hostId: 'local' }) for (const record of options.history ?? records) { const fence = record.lease.runtimeFence @@ -174,9 +176,9 @@ async function seedChatOpenedWhileOwed(record: AgentSessionRecord, tabId: string // A directory where the file belongs: the read fails in a way that can clear. await mkdir(legacyAgentSessionStorePath(root), { recursive: true }) const database = await openStructuredAgentSessionJournalDatabase({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, - hostId: 'local', - onLegacyRecordImportReport: () => undefined + hostId: 'local' }) database.db .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') @@ -190,7 +192,7 @@ async function seedChatOpenedWhileOwed(record: AgentSessionRecord, tabId: string } function startupRuntime(options: { afterInstall?: () => void; profileChats?: string[] } = {}) { - const onError = vi.fn() + const log = recordingStructuredAgentSessionLogger() const runtime = new OrcaRuntimeService() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root`, stubs the PTY daemon and gives it a profile. const internal = runtime as unknown as { @@ -203,13 +205,13 @@ function startupRuntime(options: { afterInstall?: () => void; profileChats?: str internal.hasPersistedStructuredAgentSessionStore = () => true internal.ensureStructuredAgentSessionHost = async () => { const installed = await ensureStructuredAgentSessionHost({ + logger: log.logger, stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), - onError + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) }) options.afterInstall?.() return installed @@ -241,7 +243,7 @@ function startupRuntime(options: { afterInstall?: () => void; profileChats?: str } return { runtime, - onError, + log, published: () => internal.mobileSessionTabsByWorktree.get('workspace-1')?.tabs ?? [] } } @@ -283,7 +285,7 @@ describe('restoring the chat tabs open at quit', () => { const tabWrites = spyOnTabWrites() vi.spyOn(console, 'warn').mockImplementation(() => undefined) // Writable when the store opens, then failing for good. - const { runtime, onError, published } = startupRuntime({ + const { runtime, log, published } = startupRuntime({ afterInstall: () => { writes.failing = writesFail } @@ -300,13 +302,16 @@ describe('restoring the chat tabs open at quit', () => { await expectHistory(CHAT_B) // A newer Orca's leases are adjudicated in memory, so nothing fails there. if (newer) { - expect(onError).not.toHaveBeenCalled() + expect(log.entries).toEqual([]) } else { - expect(onError).toHaveBeenCalledOnce() - expect(onError).toHaveBeenCalledWith({ - scope: 'structured-agent-session-lease-reconcile', - error: expect.objectContaining({ message: 'disk I/O error' }) - }) + expect(log.entries).toEqual([ + expect.objectContaining({ + fields: { + scope: 'lease-reconcile', + error: expect.objectContaining({ message: 'disk I/O error' }) + } + }) + ]) } expect(tabWrites.visibility).not.toHaveBeenCalled() expect(tabWrites.seed).not.toHaveBeenCalled() @@ -408,8 +413,7 @@ describe('restoring the chat tabs open at quit', () => { it('still lists the chats when that write fails, and leaves the index absent', async () => { const records = legacyChats() await seedProfile(records) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const { runtime, published } = startupRuntime({ profileChats: [CHAT_A, CHAT_B] }) + const { runtime, published, log } = startupRuntime({ profileChats: [CHAT_A, CHAT_B] }) await runtime.prepareStructuredAgentSessionStartupRestoration() writes.failing = true @@ -417,13 +421,15 @@ describe('restoring the chat tabs open at quit', () => { expect(published()).toHaveLength(2) await expectHistory(CHAT_A) - expect(warn).toHaveBeenCalledWith( - '[structured-agent-session] recording restored chat tabs failed', - { + expect(log.entries).toContainEqual({ + level: 'warn', + message: 'recording restored chat tabs failed', + fields: { + scope: 'tab-index-seed', sessionIds: [CHAT_A, CHAT_B], error: expect.objectContaining({ message: 'disk I/O error' }) } - ) + }) expect(await tabIndexOnDisk()).toBeUndefined() }) }) diff --git a/src/main/runtime/structured-agent-session-tab-index-seed.ts b/src/main/runtime/structured-agent-session-tab-index-seed.ts index 188a419f31e..3e2245148a2 100644 --- a/src/main/runtime/structured-agent-session-tab-index-seed.ts +++ b/src/main/runtime/structured-agent-session-tab-index-seed.ts @@ -11,7 +11,7 @@ type TabIndexHost = Pick< /** Best-effort: the tabs publish either way, and an index still absent is seeded again next launch. */ export async function seedStructuredAgentSessionTabIndex( - host: Partial | null | undefined, + host: (Partial & Pick) | null | undefined, targets: readonly string[], restored: readonly string[] ): Promise { @@ -27,7 +27,8 @@ export async function seedStructuredAgentSessionTabIndex( ) if (unlisted.length > 0) { await host.showSessionTabs(unlisted).catch((error: unknown) => - console.warn('[structured-agent-session] recording restored chat tabs failed', { + host.deps.logger.warn('recording restored chat tabs failed', { + scope: 'tab-index-seed', sessionIds: unlisted, error }) diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 7b2f0d2fe85..c9c3b8448ad 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -42,6 +42,7 @@ import { resetProviderFaults, type FakeConnection } from './structured-chat-coordinator-fake-codex-fixture' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const COORDINATOR = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' const PEER_CHAT = '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64' @@ -255,6 +256,7 @@ beforeEach(async () => { db = new OrchestrationDb(':memory:') runtime = startRuntime() host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts index ea3559e3059..eaf5644164f 100644 --- a/src/main/runtime/structured-child-records-switch.test.ts +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -33,6 +33,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) @@ -117,6 +118,7 @@ describe('the chat strip and the session list read the same host child records', readChildWork: (subject) => server.getStructuredChildWorkViews(subject) } const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', diff --git a/src/main/runtime/structured-claude-pending-rewind.test.ts b/src/main/runtime/structured-claude-pending-rewind.test.ts index c521ef8b6d9..47fa6d20100 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -24,6 +24,7 @@ import type { AgentSessionRecordStore } from './agent-session-record-store' import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const caller = { callerKey: 'desktop' } const PROVIDER_SESSION_ID = claudeSessionIdForOrcaSession(HOST_TEST_SESSION) @@ -37,6 +38,7 @@ let store: AgentSessionRecordStore let claude: ReturnType let adapter: ReturnType let host: StructuredAgentSessionHost +let log: ReturnType function attachParams(fence: number | null) { return hostTestAttachParams(fence, { @@ -118,7 +120,9 @@ beforeEach(async () => { readProcessStartTime: async () => HOST_TEST_NOW, onLifecycleEvent: () => {} }) + log = recordingStructuredAgentSessionLogger() host = new StructuredAgentSessionHost({ + logger: log.logger, store, // Only Claude sessions are attached here; the router supplies the production create gate. adapter: new StructuredAgentSessionAdapterRouter({ claude: adapter, codex: adapter }, () => @@ -173,7 +177,6 @@ describe('Claude rewind is unsupported', () => { ) it('still attaches when settling the pending rewind fails, and logs it', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) await seedPendingRewind('prepared') const transition = store.transitionHandoff.bind(store) vi.spyOn(store, 'transitionHandoff').mockImplementation((sessionId, apply) => @@ -189,9 +192,14 @@ describe('Claude rewind is unsupported', () => { await reattach() expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('prepared') - expect(warn).toHaveBeenCalledWith( - '[structured-rewind] pending Claude rewind was not settled:', - expect.objectContaining({ sessionId: HOST_TEST_SESSION, error: expect.any(Error) }) + expect(log.entries).toContainEqual( + expect.objectContaining({ + fields: expect.objectContaining({ + scope: 'rewind-unsupported-settlement', + sessionId: HOST_TEST_SESSION, + error: expect.any(Error) + }) + }) ) }) }) diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index 07de5e58761..7a69cbe9c86 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -20,6 +20,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' /** The error a real CLI's exit reaches the adapter as: Orca's message, the stderr as a log detail. */ export function scriptedClaudeExitError(diagnostic: string): Error { @@ -223,6 +224,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { await mkdir(join(root, 'claude-home'), { recursive: true }) const directory = root return ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: directory, hostId: 'local', claimKeyId: 'key-1', diff --git a/src/main/runtime/structured-codex-child-work-runtime.test.ts b/src/main/runtime/structured-codex-child-work-runtime.test.ts index d03ab6f1be5..5e0cf36f14d 100644 --- a/src/main/runtime/structured-codex-child-work-runtime.test.ts +++ b/src/main/runtime/structured-codex-child-work-runtime.test.ts @@ -19,6 +19,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const THREAD = 'thread-runtime-child-work' const CHILD = 'thread-runtime-reviewer' @@ -70,6 +71,7 @@ describe('structured Codex child work through the production runtime', () => { NonNullable >[] = [] const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', diff --git a/src/main/runtime/structured-codex-session-rpc-test-harness.ts b/src/main/runtime/structured-codex-session-rpc-test-harness.ts index 9d6c0ef30fb..81e7d3f1d8c 100644 --- a/src/main/runtime/structured-codex-session-rpc-test-harness.ts +++ b/src/main/runtime/structured-codex-session-rpc-test-harness.ts @@ -28,6 +28,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' export const SESSION = 'session-integration-1' export const THREAD = 'thread-integration' @@ -206,6 +207,7 @@ export async function openStructuredCodexRpcHarness( }) }) const hostConfig = (): Parameters[0] => ({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', diff --git a/src/main/runtime/structured-session-worktree-teardown.ts b/src/main/runtime/structured-session-worktree-teardown.ts index 3f8ab31b3ec..047b4a21644 100644 --- a/src/main/runtime/structured-session-worktree-teardown.ts +++ b/src/main/runtime/structured-session-worktree-teardown.ts @@ -336,12 +336,14 @@ export async function retireStructuredSessionTabsForWorktree( * proved after that close had already rolled the hide back. */ async function dropDurableChatTabReference(sessionId: string): Promise { + const host = getStructuredAgentSessionHost() try { - await getStructuredAgentSessionHost()?.setSessionTabVisibility?.(sessionId, false) + await host?.setSessionTabVisibility?.(sessionId, false) } catch (error) { - console.warn( - `[worktree-teardown] could not drop the chat tab reference for ${sessionId}`, + host?.deps.logger.warn('dropping a removed workspace chat tab reference failed', { + scope: 'teardown-tab-drop', + sessionId, error - ) + }) } } diff --git a/tests/e2e/codex-child-approval-activity-row.unit.test.ts b/tests/e2e/codex-child-approval-activity-row.unit.test.ts index c2df9b1f3e3..27f00644ec1 100644 --- a/tests/e2e/codex-child-approval-activity-row.unit.test.ts +++ b/tests/e2e/codex-child-approval-activity-row.unit.test.ts @@ -71,6 +71,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { StructuredAgentSessionStatusBridge } from '../../src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge' import { resetStructuredAgentSessionStatusFeedsForTests } from '../../src/renderer/src/runtime/structured-agent-session-status-feed' +import { createStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' +import { testEventSinkLogging } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger-test-support' const SESSION = 'codex-child-approval' const CODEX_THREAD = 'thread-parent' @@ -143,6 +145,7 @@ async function openHost() { stateDirectory: join(root, SESSION) }) const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([ [SESSION, indexedStatusFeedSession({ journal, child: { phase: 'ready' } })] ]), @@ -151,7 +154,7 @@ async function openHost() { }) const events: AgentSessionStatusEvent[] = [] feed.subscribe({ id: 'renderer', emit: (event) => events.push(event) }) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const publish = (): void => feed.publish(SESSION, journal) deferred.bind({ journal, fence: 1, publish }) const prompts: string[] = [] diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 4aaa02037ec..4969357ba87 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -62,6 +62,7 @@ import { type RpcReply } from './versioned-agent-session-wire' import { openTestJournalHostDatabase } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' // Why: a cold CI run extracts the baseline checkout before the first pairing. const SUITE_TIMEOUT_MS = 180_000 @@ -475,6 +476,7 @@ describe('cross-version structured agent sessions', () => { root = await mkdtemp(join(tmpdir(), 'orca-cross-version-ai-vault-')) store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire: async ({ fence }) => ({ @@ -701,6 +703,7 @@ describe('cross-version structured agent sessions', () => { async function bootHost(generation: string): Promise { store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), diff --git a/tests/e2e/structured-agent-session-read-owner.unit.test.ts b/tests/e2e/structured-agent-session-read-owner.unit.test.ts index dafa6e8089d..b7734f2759d 100644 --- a/tests/e2e/structured-agent-session-read-owner.unit.test.ts +++ b/tests/e2e/structured-agent-session-read-owner.unit.test.ts @@ -32,6 +32,7 @@ import { getStructuredAgentSessionReadOwner, resetStructuredAgentSessionReadOwnersForTests } from '../../src/renderer/src/components/native-chat/structured-agent-session-read-owner' +import { createStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' const SESSION = 'cursor-body-regression' const target = { kind: 'local' } as const @@ -125,6 +126,7 @@ describe('structured session cursor/body regression', () => { const { journal, appendOutput, accept } = await fixture() let hostSummary: AgentSessionStatusSummary | undefined const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([ [ SESSION, diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts index e656b320b62..a5827937358 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -25,6 +25,7 @@ import { runWorktreeAgentActivationGate } from '../../src/renderer/src/lib/workt import { readWorktreeStructuredActivationInventory } from '../../src/renderer/src/lib/worktree-agent-structured-inventory' import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' import { openTestJournalHostDatabase } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' const WORKTREE = 'repo-1::/workspace/repo' @@ -36,6 +37,7 @@ let closeSession: Mock ({