From 9a44a4dda51cae923ae8c424442cb39b0f76bcac Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:29:11 -0700 Subject: [PATCH 1/8] refactor(native-chat): give the structured chat host one required logger The structured chat runtime took an optional onError callback that the desktop never passed, so a late dispatch settlement, an unanswered-dispatch release, a journal event-sink write and a provider lifecycle delivery that failed were dropped with no trace. Other host failures went to scattered console.warn calls, which reach nothing in a packaged desktop build. The runtime and host now take one required logger (warn/error with a scope and fields). The production logger writes each entry as a failed span to /logs/main.trace.ndjson, which the diagnostic bundle collects, and to the console (stderr under a supervised headless host). The runtime and the host wrap it so a logger that throws never fails what it reports, and the install refuses without one. Sites that deliberately kept a recovery-capsule error out of the log still log no error object. --- .../first-work-branch-rename.test.ts | 3 + .../claude-api-retry-idle-sweep.test.ts | 2 + .../claude-background-wake-retention.test.ts | 2 + .../codex-provider-retry-idle-sweep.test.ts | 2 + .../codex-structured-question-order.test.ts | 2 + .../journal-per-session-import.test.ts | 2 + ...-agent-session-accept-then-deliver.test.ts | 2 + ...-agent-session-acquisition-options.test.ts | 11 + ...tured-agent-session-adopted-import.test.ts | 3 + .../structured-agent-session-agent-start.ts | 6 +- ...ured-agent-session-append-delivery.test.ts | 2 + .../structured-agent-session-attach-flow.ts | 8 +- ...ured-agent-session-attach-orchestration.ts | 3 +- ...-agent-session-claude-compact-stop.test.ts | 2 + ...-agent-session-claude-echo-working.test.ts | 2 + ...-agent-session-claude-option-queue.test.ts | 2 + ...d-agent-session-claude-queued-stop.test.ts | 2 + ...red-agent-session-claude-root-exit.test.ts | 4 +- ...ctured-agent-session-close-verdict.test.ts | 2 + ...tured-agent-session-codex-stop-row.test.ts | 2 + ...red-agent-session-conversation-lifetime.ts | 3 +- ...uctured-agent-session-conversation-open.ts | 16 +- ...ed-agent-session-conversation-stop.test.ts | 2 + ...ured-agent-session-crash-mid-start.test.ts | 2 + ...tured-agent-session-crash-turn-end.test.ts | 9 +- ...tructured-agent-session-event-sink.test.ts | 7 +- .../structured-agent-session-eviction.test.ts | 2 + .../structured-agent-session-eviction.ts | 7 +- ...ession-failed-create-owner-verdict.test.ts | 2 + ...session-failed-create-sink-release.test.ts | 2 + ...ctured-agent-session-forget-status.test.ts | 2 + ...tured-agent-session-grouped-prompt.test.ts | 2 + .../structured-agent-session-host-delivery.ts | 29 +- .../structured-agent-session-host-lifetime.ts | 15 +- ...structured-agent-session-host-mutations.ts | 1 + ...d-agent-session-host-runtime-state.test.ts | 22 +- ...ctured-agent-session-host-runtime-state.ts | 13 +- .../structured-agent-session-host-tabs.ts | 7 +- ...ctured-agent-session-host-teardown.test.ts | 12 +- .../structured-agent-session-host-teardown.ts | 13 +- ...ed-agent-session-host-test-abandon.test.ts | 2 + ...uctured-agent-session-host-test-harness.ts | 2 + .../structured-agent-session-host-types.ts | 9 +- .../structured-agent-session-host.test.ts | 3 + .../structured-agent-session-host.ts | 7 +- ...ured-agent-session-import-mismatch.test.ts | 2 + ...d-agent-session-journal-corruption.test.ts | 13 +- ...ured-agent-session-late-settlement.test.ts | 2 + ...gent-session-legacy-handoff-record.test.ts | 2 + ...tured-agent-session-logger-test-support.ts | 27 ++ .../structured-agent-session-logger.ts | 89 ++++++ ...ssion-main-agent-working-agreement.test.ts | 2 + ...ctured-agent-session-mutation-admission.ts | 12 +- ...agent-session-operation-settlement.test.ts | 2 + ...ured-agent-session-operation-settlement.ts | 13 +- ...nt-session-operation-start-refusal.test.ts | 9 +- ...ed-agent-session-option-settlement.test.ts | 2 + ...ed-agent-session-owed-work-release.test.ts | 2 + ...ent-session-pre-spawn-first-answer.test.ts | 9 +- ...nt-session-processless-reservation.test.ts | 7 + ...ctured-agent-session-prompt-cancel.test.ts | 2 + ...gent-session-provider-child-record.test.ts | 2 + ...red-agent-session-provider-restore.test.ts | 2 + ...red-agent-session-provider-started.test.ts | 2 + ...session-queued-message-rig.test-fixture.ts | 2 + ...ructured-agent-session-queued-mutations.ts | 6 +- .../structured-agent-session-queued-pause.ts | 9 +- .../structured-agent-session-queued-stop.ts | 6 +- .../structured-agent-session-queued-wiring.ts | 9 +- ...agent-session-recovered-turn-clock.test.ts | 2 + ...tured-agent-session-recovery-exits.test.ts | 2 + ...ctured-agent-session-refusal-retry.test.ts | 2 + .../structured-agent-session-rest-test-rig.ts | 2 + ...ructured-agent-session-restart-cap.test.ts | 3 +- ...agent-session-restart-continuation.test.ts | 3 +- ...ured-agent-session-restart-continuation.ts | 39 ++- ...ed-agent-session-restart-failure-ledger.ts | 21 +- ...ssion-restart-interruption-test-harness.ts | 2 + ...red-agent-session-restart-offer-records.ts | 10 +- ...t-session-restart-offer-withdrawal.test.ts | 3 + ...-agent-session-restart-offer-withdrawal.ts | 7 +- ...ed-agent-session-restart-ownership.test.ts | 18 +- ...uctured-agent-session-restart-reconcile.ts | 18 +- ...tured-agent-session-restart-resume-host.ts | 15 +- ...red-agent-session-restart-resume-wiring.ts | 6 +- ...session-restart-status-publication.test.ts | 2 + ...red-agent-session-restart-teardown.test.ts | 15 +- ...ent-session-restore-without-import.test.ts | 2 + .../structured-agent-session-reveal.ts | 2 +- ...tured-agent-session-rewind-at-rest.test.ts | 2 + .../structured-agent-session-rewind.test.ts | 2 + .../structured-agent-session-rewind.ts | 3 +- ...tructured-agent-session-send-block.test.ts | 11 +- ...red-agent-session-send-idempotency.test.ts | 2 + ...agent-session-send-open-stale-turn.test.ts | 2 + ...red-agent-session-send-preparation.test.ts | 19 +- ...ructured-agent-session-send-preparation.ts | 28 +- ...session-send-restarts-failed-start.test.ts | 2 + ...agent-session-settled-attach-retry.test.ts | 7 + ...agent-session-start-failure-writer.test.ts | 2 + ...-session-startup-reconcile-failure.test.ts | 18 +- ...ent-session-status-feed-child-work.test.ts | 2 + ...ed-agent-session-status-feed-child.test.ts | 2 + ...agent-session-status-feed-children.test.ts | 2 + ...ed-agent-session-status-feed-clock.test.ts | 2 + ...ructured-agent-session-status-feed.test.ts | 2 + .../structured-agent-session-status-feed.ts | 24 +- ...tured-agent-session-status-reentry.test.ts | 2 + ...red-agent-session-subagent-recency.test.ts | 2 + ...ructured-agent-session-subscribers.test.ts | 2 + ...red-agent-session-surface-lifetime.test.ts | 5 +- ...ructured-agent-session-thread-goal.test.ts | 2 + .../structured-agent-session-turns.test.ts | 7 + .../structured-agent-session-turns.ts | 2 + ...sion-view-start-after-failed-start.test.ts | 2 + ...t-session-wedged-profile-migration.test.ts | 2 + .../structured-conversation-command.test.ts | 2 + .../structured-conversation-command.ts | 1 + .../structured-rewind-recovery.ts | 13 +- ...agent-session-legacy-record-import.test.ts | 14 +- ...ude-structured-session-integration.test.ts | 2 + .../runtime/orca-runtime-get-worktree-ps.ts | 3 + ...time-structured-status-sink-wiring.test.ts | 13 + ...ured-agent-session-adoption-replay.test.ts | 2 + .../structured-agent-session-hold.test.ts | 2 + ...ructured-agent-session-rpc.test-fixture.ts | 2 + .../methods/structured-chat-tab-table.test.ts | 2 + ...untime-worktree-agent-rows-verdict.test.ts | 2 + ...ree-structured-agent-rows-liveness.test.ts | 2 + ...-session-codex-turn-end-settlement.test.ts | 2 + ...ctured-agent-session-dispatch-followups.ts | 52 ++++ ...ured-agent-session-failure-logging.test.ts | 260 ++++++++++++++++++ ...uctured-agent-session-host-refusal.test.ts | 22 +- ...ured-agent-session-install-failure.test.ts | 2 + ...ructured-agent-session-integration.test.ts | 10 +- .../structured-agent-session-journal-open.ts | 26 +- ...ctured-agent-session-lifecycle-delivery.ts | 10 +- ...gent-session-record-import-install.test.ts | 31 ++- ...uctured-agent-session-runtime-exit.test.ts | 6 + .../structured-agent-session-runtime.test.ts | 3 + .../structured-agent-session-runtime.ts | 62 ++--- ...nt-session-spawn-token-descendants.test.ts | 2 + ...ed-agent-session-startup-reconcile.test.ts | 48 ++-- ...-agent-session-startup-tab-restore.test.ts | 35 ++- .../structured-chat-coordinator-mail.test.ts | 2 + .../structured-child-records-switch.test.ts | 2 + .../structured-claude-pending-rewind.test.ts | 16 +- ...ed-claude-scripted-runtime-test-support.ts | 2 + ...tructured-codex-child-work-runtime.test.ts | 2 + ...ructured-codex-session-rpc-test-harness.ts | 2 + ...x-child-approval-activity-row.unit.test.ts | 2 + ...ss-version-agent-session-wire.unit.test.ts | 3 + ...ured-agent-session-read-owner.unit.test.ts | 2 + ...-chat-owner-status-activation.unit.test.ts | 2 + 154 files changed, 1201 insertions(+), 285 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-logger-test-support.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-logger.ts create mode 100644 src/main/runtime/structured-agent-session-dispatch-followups.ts create mode 100644 src/main/runtime/structured-agent-session-failure-logging.test.ts 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 63b6165ce3aa..0e17feb98747 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 bac1da60fe73..664f9c135da6 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-background-wake-retention.test.ts b/src/main/claude/claude-background-wake-retention.test.ts index 313f9c5de590..a865e1c1b76e 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/codex/codex-provider-retry-idle-sweep.test.ts b/src/main/codex/codex-provider-retry-idle-sweep.test.ts index b1e8b8b6510c..39291b11b195 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-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index 069fe4eb048e..8ad4b75bd38e 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-per-session-import.test.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts index ea28ba4ef838..8f96ce4d852d 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 a80c147607df..b3f91aea7002 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 a0d2f0fdb7dd..c98854292d5c 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 6bb43fb9d31d..76a276abd230 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 a4c93fa00f88..6fbcca0dce24 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 bda7cc9330f2..e2adb2953cb4 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-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index c469035c4689..d1b6cd280875 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 9cfdf6a9b87c..d8f89065de4f 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-claude-compact-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts index b456bacac26e..c6f13c69e82f 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 1ba4f13e4ea8..747319c3c66b 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 6398d96ab0a1..1bb3d36a5c43 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 f78be712f7cf..c99b8e724640 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 778bd3cc2fa9..f09fe672b5ec 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-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index 83d80437de3c..4b51f9cfe77a 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 8833ac4ee31b..9103393c26c1 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-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 1980616f1765..f9d479add4ce 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 @@ -88,7 +88,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { cause: 'host-stop' }), closeConversation, - onError: (sessionId, error) => deps().onEventSinkError?.({ sessionId, error }), + onError: (sessionId, error) => + deps().logger.warn('an idle sweep step failed', { scope: 'idle-sweep', sessionId, error }), ...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 ec938f17f13b..73285ee1de4a 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 2eeae4787ce5..b5f8dda0b4ec 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' } const ALREADY_FINISHED = 'The provider had already finished this turn.' @@ -51,6 +52,7 @@ beforeEach(async () => { awaitStarted = vi.fn(async () => undefined) 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-crash-mid-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts index 22295ea6e864..059567386a6d 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 9d396b58ffa7..7e2376150891 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-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index 46dc8fbf7e44..ed8ea7b3b462 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,7 @@ 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' const BODY: AgentJournalItemBody = { kind: 'message', @@ -206,7 +207,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 }) 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 38349f95115e..d7d6111659de 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,14 @@ import { AgentSessionPreSpawnError } from './structured-agent-session-adapter' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' 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 () => { 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 fc45a2c77154..f7aff9cd1f2d 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 { 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 d1a45fc469a7..fca04e1028ac 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 06538d783109..38b85ca12d1f 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 fdaa5b72d506..c0e78b5a0c52 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 7c37b9542536..b22ef412948e 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 5d8a4d0429ba..3d625b0ccfae 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,12 @@ export function createStructuredAgentSessionConversationDelivery(input: { deps.store.getRecord(sessionId), sessions.get(sessionId)?.journal ), - onError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }), + onError: (sessionId, error) => + deps.logger.warn('delivering a queued message failed', { + scope: 'delivery-loop', + sessionId, + error + }), record: (sessionId) => deps.store.getRecord(sessionId), readChildWork: input.clientDelivery.readChildWork, flushStreamedEvents: input.flushStreamedEvents, @@ -116,7 +121,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 +138,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 +173,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 719540da5da8..370c3ddda4eb 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 } ) @@ -121,6 +125,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 @@ -156,7 +161,11 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( showUnexpectedExitOutcome: false, onError: (id, error) => { settlementError = error - context.deps.onEventSinkError?.({ sessionId: id, error }) + context.deps.logger.warn("settling a closed agent's work failed", { + scope: 'close-settlement', + sessionId: id, + error + }) } }) if (!settled) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index c88ae3a2f52f..4b62839f5c4b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -95,6 +95,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-host-runtime-state.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts index 5f336345b5e6..e8421b028933 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 63f37a37590e..cd80ed14abf5 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,12 @@ 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 }) + onError: ({ sessionId, error }) => + deps.logger.warn('renewing a chat lease failed', { + scope: 'lease-renewal', + sessionId, + error + }) }) } @@ -68,7 +73,11 @@ export class StructuredAgentSessionHostRuntimeState { const minted: DeferredStructuredAgentSessionEventSink = createDeferredStructuredAgentSessionEventSink({ onError: (error) => { - this.deps.onEventSinkError?.({ sessionId, error }) + this.deps.logger.error('writing provider events to the chat journal failed', { + scope: 'journal-event-sink', + sessionId, + 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 8a18040daf35..5c9f5215a95e 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 aa8692a0c73f..e6f62d6d780c 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 e6700777227c..65bf219cf79d 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 2971ce4f79c6..ee0bb2bee929 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 f29d1b68e69c..8d6bee128bb9 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 { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const journals = createTrackedJournalOpener() @@ -153,6 +154,7 @@ beforeEach(async () => { store = await openTestAgentSessionRecordStore(root) recoveryCapsule = new TrackedTestRecoveryCapsule(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-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index c2ec18332882..dcf708643e99 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 46bd84905921..6c747fbdd4db 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 c7e91ec8dde6..91c89d7d1b1a 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 { logSessionFailure, withNeverThrowingLogger } 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 }), + onDeliveryError: logSessionFailure(() => this.deps.logger, 'journal-delivery', 'not delivered'), 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 = withNeverThrowingLogger(deps) this.backgroundTasks = new StructuredAgentSessionBackgroundTaskChannel( deps, this.sessions, @@ -158,7 +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 }) + onBarrierError: logSessionFailure(() => deps.logger, 'lifecycle-barrier', 'barrier failed') }) this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, { ...structuredAgentSessionRestartResumeSurfaces(this, this.now), 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 a173a3ad6619..5a384ce2bb9e 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 ac4bb5e2f4d4..bd7d735abbc9 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 44615a27ee82..f91fc57c0558 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-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index 706d0d8fc3f3..c25640cc81cc 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-logger-test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-logger-test-support.ts new file mode 100644 index 000000000000..8b897a5aaa6f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-logger-test-support.ts @@ -0,0 +1,27 @@ +import type { + StructuredAgentSessionLogFields, + 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) + } +} 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 000000000000..de00a6e95925 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-logger.ts @@ -0,0 +1,89 @@ +// 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 app's local trace file (`/logs/main.trace.ndjson`, collected by the diagnostic +// bundle) and to the console, which is stderr under a supervised headless host. + +import { startSpan } from '../../observability/tracer' + +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) } +} + +/** A `(sessionId, error)` reporter for a collaborator that reports per session. Lazy, for a host + * that wires its collaborators before its own deps are assigned. */ +export function logSessionFailure( + logger: () => StructuredAgentSessionLogger, + scope: string, + message: string +): (sessionId: string, error: unknown) => void { + return (sessionId, error) => logger().warn(message, { scope, sessionId, error }) +} + +/** The production logger: a failed span in the local trace file, plus the console. */ +export function createStructuredAgentSessionLogger(): StructuredAgentSessionLogger { + const write = + (level: LogLevel) => + (message: string, fields: StructuredAgentSessionLogFields): void => { + const { scope, error, ...rest } = fields + const span = startSpan(`agentSession.${scope}`, { + attributes: { + level, + message, + ...rest, + // An Error's own fields do not enumerate; the span's failure cause carries its stack. + ...(error !== undefined && !(error instanceof Error) ? { error } : {}) + } + }) + span.fail(error instanceof Error ? error : message) + const print = level === 'error' ? console.error : console.warn + print(`[agent-session] ${scope}: ${message}`, fields) + } + return neverThrowingStructuredAgentSessionLogger({ warn: write('warn'), error: write('error') }) +} 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 aa4a95470011..9ab31743cf4b 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 3e96a02ce26a..6199531c8ee5 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-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts index 71750505fef1..50c887c85df7 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 e4675bfefb14..24de6ff708bc 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 7700a616b382..47aecdaa00cc 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 aa7b8a17295f..a544e9e1761f 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-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index 24e9dc66f446..8b2586a9ccba 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 db9406785f87..1e171e64199e 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 9df12ea96fde..b3880fb85f12 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), @@ -307,6 +313,7 @@ describe('processless structured session reservation', () => { await expect( performAttach({ + logger: createStructuredAgentSessionLogger(), ...input, authority: { ...input.authority, 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 b9a7bc7c5c47..7db80eb7371c 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 @@ -8,6 +8,7 @@ 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 { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -62,6 +63,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 230709e5d213..e387d2a5e99a 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 411e148c586c..de2951a6292a 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 f5e0e14b09fb..2922c3fdb2ec 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-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index d63af3ba7d20..a9a1089cc1c1 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-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index 9da974a44a43..b81803c0dbb9 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 1428de75fe63..f96716d03049 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 16a9b4aeaf77..c715f5accd95 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 2b193d5cb82f..27f6ad959294 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 @@ -33,7 +33,12 @@ export function wireStructuredAgentSessionQueuedMessages( conversationFence: (sessionId) => structuredAgentSessionConversationFence(context().deps.store, sessionId), wakeDelivery: (sessionId) => context().wakeDelivery(sessionId), - onError: (sessionId, error) => context().deps.onEventSinkError?.({ sessionId, error }) + onError: (sessionId, error) => + context().deps.logger.warn('draining queued messages failed', { + scope: 'queued-drain', + sessionId, + error + }) }) return { drain, @@ -44,7 +49,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-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index 538fb0b9dbc6..0849fa5aa824 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, 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 f69fbad10316..228fa21fbc24 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 83082bf923a5..d5899c794e3c 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-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index e441a156b494..0045684ccb37 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 9ded5b54d983..155785ae35ef 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 0fe36994cd7c..973d7758358d 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 8038e90e1fc4..27c09bc2bbe1 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 ee769b9e8c33..63722d1737cc 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 9bb2173f42fc..16cf2704f9a8 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 { createStructuredAgentSessionLogger } from './structured-agent-session-logger' /** 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. */ @@ -125,6 +126,7 @@ export async function interruptedRestart( // The relaunch comes after the quit that recorded the offer. const clock = { now: NOW + 1 } const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter(), 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 f9530039cd4a..95c97be63a92 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 d36e5702d1dc..b155674babf0 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 76d8006d9a1a..4924860b05f2 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 59ff6958d53d..a810f0db1350 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()) @@ -69,7 +70,8 @@ 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 } ) } finally { write.mockRestore() @@ -335,8 +337,13 @@ 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)) + const withdrawing = '[agent-session] restart-offer-withdraw: withdrawing a restart offer failed' + await vi.waitFor(() => + expect(warning).toHaveBeenLastCalledWith(withdrawing, { + scope: 'restart-offer-withdraw', + sessionId: SESSION + }) + ) expect(warning.mock.calls.filter(([message]) => message !== withdrawing)).toHaveLength(5) warning.mockRestore() }) @@ -451,9 +458,10 @@ 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.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 41dd7913d85a..29f0c9f1047d 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-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index af2a0970a6fc..ef8481e2b1f5 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 bb9eb500d1db..2284367c14b8 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 35425a70afe8..6cde8d87b842 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 d611bdb1589b..efd3ccc9e6c9 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,6 +16,7 @@ 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', @@ -30,14 +31,18 @@ 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) 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 2ea475cf3bab..96fb0ca9bd08 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,7 @@ 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' const { readOnlyOpens, openReadOnly } = vi.hoisted(() => ({ readOnlyOpens: new Array(), @@ -377,6 +378,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() 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 262c9e18d3c6..3046aa971f67 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 b34de39b74f1..2235754d0f23 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 35e7d5af2e27..6cc37ccb724f 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 6358cdfe0f06..f2a1c088e261 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 61696b573c05..bcfeadb67271 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 1925c13378ee..7cdbbaaa5c29 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,7 @@ 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' const journals = createTrackedJournalOpener() @@ -97,6 +98,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 c85c628d9175..c30c33d1f434 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 86873faf6b0a..cf746e29b740 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 be29089991bd..4b2011a02eee 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 21e50e4dca60..8c5e609b971b 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 17bb4b0ee1fc..f14393df8740 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 e5527d1a5afb..792276702381 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-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts index d4f8bce00332..a4470f81a457 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) + // The lease-reconcile entries the host logs, by the failure each reports. const onLeaseReconcileFailure = vi.fn() const host = new StructuredAgentSessionHost({ + logger: { + warn: (_message, fields) => { + if (fields.scope === 'lease-reconcile') { + onLeaseReconcileFailure(fields.error) + } + }, + error: () => undefined + }, store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(relaunched), @@ -92,8 +101,7 @@ 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 } @@ -214,7 +222,7 @@ it('restores a chat for reading when resolving its recovery cannot write the sto 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) => { +])('keeps the %s resolving when the logger throws', async (_step, read) => { const { host, onLeaseReconcileFailure } = await relaunch() const sinkError = new Error('error sink failed') onLeaseReconcileFailure.mockImplementation(() => { @@ -227,8 +235,8 @@ it.each([ expect(onLeaseReconcileFailure).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 03c490742ce3..cfb801c8fd6b 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 545a0ebb4600..c6777d8a07a0 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 c4e8100c30ae..30ddeea9678b 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 c39228522454..09a3442985b1 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 8062b740bcbf..ed2bea3377ed 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,7 @@ import { type StructuredAgentSessionStatusFeedDeps, type StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const SESSION = 'status-session' const TURN_IDENTITY = { @@ -79,6 +80,7 @@ function feedFor( statusSink ?? (readChildWork ? { publish: () => {}, forget: () => {}, readChildWork } : undefined) const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), ...(onStatusChanged ? { onStatusChanged } : {}), ...(sink ? { statusSink: () => sink } : {}), sessions: { 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 4f717d2c31be..19ad9535ac9c 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,7 @@ import { type StructuredAgentSessionStatusState } from './structured-agent-session-status-journal-projection' import { structuredStatusSummariesEqual } from './structured-agent-session-status-summary-equality' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import { StructuredAgentSessionStatusOwnership, type StructuredAgentSessionStatusSink @@ -52,6 +53,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 +75,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: { now: () => number deps: () => { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } + logger: StructuredAgentSessionLogger onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] statusSink?: StructuredAgentSessionStatusSink } @@ -82,6 +86,10 @@ export function createStructuredAgentSessionHostStatusFeed(args: { sessions: args.sessions, getRecord: (sessionId) => args.deps().store.getRecord(sessionId), now: args.now, + logger: { + warn: (message, fields) => args.deps().logger.warn(message, fields), + error: (message, fields) => args.deps().logger.error(message, fields) + }, 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 fff0ae2383b8..aea5e93168a9 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 dafe54e17901..1d5a3150c10d 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,7 @@ 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' const SESSION = 'recency-session' const CODEX_THREAD = 'thread-parent' @@ -60,6 +61,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, 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 cf31d1d47f1c..3881e8c429f2 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 871c05a6e337..6ef96b7f98cd 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 c9cdaa997b13..7e59f695d6d4 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 7c5ecf1ee15d..23b1619a4c92 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, @@ -183,6 +187,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, @@ -221,6 +226,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, @@ -300,6 +306,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 13aa9d72aea0..6ee38fa1d62a 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-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 3d38c1cb38d2..20c5abd595e8 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 2981815c1f17..08532f63c1fd 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 89b07045b3d5..887357f59026 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 bbc7aea52fb7..ac62bb591860 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 @@ -151,6 +151,7 @@ export function runStructuredConversationCommand( admitAndRunAgentSessionMutation({ store, adapter: context.deps.adapter, + logger: context.deps.logger, callerKey: caller.callerKey, envelope, // Only the provider can do this, so an agent at rest is started first. 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 b572e51e5f39..a45b821e465c 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/runtime/agent-session-legacy-record-import.test.ts b/src/main/runtime/agent-session-legacy-record-import.test.ts index da09cb2257d8..bf2156d0710b 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 dc88bf858aff..e3b3d3482d30 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-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index adcd47baad30..ddeb59f126d4 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 desktop's trace file, or stderr under a supervised headless host. + 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-structured-status-sink-wiring.test.ts b/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts index b931af574632..09afcf4b67ca 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 @@ -88,6 +88,19 @@ 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. + it('hands the host the trace-file logger', async () => { + installed.deps = null + const runtime = new OrcaRuntimeService() + + await runtime.ensureStructuredAgentSessionHost() + + expect(installed.deps?.['logger']).toMatchObject({ + warn: expect.any(Function), + error: expect.any(Function) + }) + }) + 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/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 64c7ec1c8b18..6d1ddf9f3594 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-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 45c01c2c63ac..446fb1074113 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-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 85d3e2a5b4bf..6c9be24ca4b2 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,7 @@ 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' export const SESSION = 'session-alpha' export const FINGERPRINT = 'f'.repeat(64) @@ -94,6 +95,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, 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 42e7101e9484..44cd79fa34f1 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 @@ -37,6 +37,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}` @@ -92,6 +93,7 @@ function providerAdapter(): StructuredAgentSessionAdapter { async function openHost(): Promise { store = await openTestAgentSessionRecordStore(directory) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: providerAdapter(), journalDatabase: openTestJournalHostDatabase(directory), 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 0d9180dc3e57..25379632875b 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 50e974f1eb5e..5afd2d2e7701 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-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index facd5e15b05b..6545ffb2ecf1 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 000000000000..dbc5a31b0d1f --- /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 000000000000..97fa5bf81db8 --- /dev/null +++ b/src/main/runtime/structured-agent-session-failure-logging.test.ts @@ -0,0 +1,260 @@ +// 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 { + 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('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 d05c9a78422a..3ef5965b4e80 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 4b00cdebda60..c00e9c6890f2 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 cf41fdb1dfe0..3d9e72f74046 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 6dd9d964d48d..54c27fbfe963 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 961c4d50aa84..ccf026de359c 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 551fab477c41..dd76d01e6c64 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 3da0c6f203d2..2727b2389f2f 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 e1d37c452a1e..f572e8909833 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 aecf034b3708..3785fdeaf5ce 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 d86c54282c10..aee47ab15bae 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 32b30a5dc779..a6417d3b1499 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,41 +106,28 @@ 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 runtime = startupRuntime() await runtime.prepareStructuredAgentSessionStartupRestoration() const host = getStructuredAgentSessionHost() const close = vi.spyOn(host!, '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 84c14b2e23cd..6f57a1f498f9 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() diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 4ffbec11118e..baa6eba8b5b6 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -41,6 +41,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' @@ -244,6 +245,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 ea3559e30592..eaf5644164f8 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 c521ef8b6d95..47fa6d201009 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 07de5e58761a..7a69cbe9c86d 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 d03ab6f1be5d..5e0cf36f14d7 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 9d6c0ef30fbc..81e7d3f1d8c4 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/tests/e2e/codex-child-approval-activity-row.unit.test.ts b/tests/e2e/codex-child-approval-activity-row.unit.test.ts index c2df9b1f3e3f..a9d1370542c0 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,7 @@ 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' const SESSION = 'codex-child-approval' const CODEX_THREAD = 'thread-parent' @@ -143,6 +144,7 @@ async function openHost() { stateDirectory: join(root, SESSION) }) const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([ [SESSION, indexedStatusFeedSession({ journal, child: { phase: 'ready' } })] ]), 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 4aaa02037ec3..4969357ba878 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 dafa6e8089df..b7734f2759d2 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 e656b320b627..a58279373587 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 ({ From 11cee0c1b018995b1b8ff2bf8d1eb4efa5cc5eff Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:56:34 -0700 Subject: [PATCH 2/8] refactor(native-chat): hand the chat host's collaborators the logger, and give orcad its trace file The delivery loop, idle sweep, queued-message drain, lease renewer, event sink, conversation map and provider start/exit settlement each took an internal error callback that the host mapped onto the logger. They now take the logger itself and log under their own scope. The event sink keeps one onFailed hook, which decides whether to stop the provider, not whether to report. The dead-generation settlement returns its failure so each caller logs it under its own scope. orcad now installs the desktop's local trace sink under its own data root, so a headless host's chat failures reach /logs/main.trace.ndjson as well as stderr. Also passes the logger in the test fixtures the first commit missed, which tc:node caught. --- src/main/claude/claude-api-retry-row.test.ts | 3 +- .../claude-context-usage-restart.test.ts | 3 +- ...claude-context-usage-unloaded-turn.test.ts | 3 +- .../claude-stream-json-connection.test.ts | 3 +- ...urnal-translation-background-tasks.test.ts | 12 +-- ...ude-structured-journal-translation.test.ts | 5 +- ...laude-structured-provider-fallback.test.ts | 3 +- ...structured-session-reading-control.test.ts | 2 + ...aude-subagent-resume-after-restart.test.ts | 5 +- .../claude/claude-subagent-roster.test.ts | 3 +- ...dex-structured-journal-goal-resume.test.ts | 8 +- ...x-structured-journal-goal-revision.test.ts | 3 +- ...tructured-journal-subagent-readers.test.ts | 3 +- ...red-journal-translation-settlement.test.ts | 2 + ...journal-translation-turn-lifecycle.test.ts | 3 +- ...dex-structured-journal-translation.test.ts | 3 + ...-journal-turn-settles-once-journal.test.ts | 3 +- ...-agent-session-attach-test-conversation.ts | 11 ++- ...nt-session-background-task-channel.test.ts | 3 +- ...d-agent-session-conversation-close.test.ts | 10 ++- ...red-agent-session-conversation-lifetime.ts | 3 +- ...ctured-agent-session-conversations.test.ts | 19 ++-- .../structured-agent-session-conversations.ts | 9 +- ...session-dead-generation-settlement.test.ts | 14 +-- ...gent-session-dead-generation-settlement.ts | 16 ++-- .../structured-agent-session-delivery-loop.ts | 15 +++- ...structured-agent-session-event-recovery.ts | 18 +++- ...ructured-agent-session-event-sink-queue.ts | 5 +- ...tructured-agent-session-event-sink.test.ts | 37 ++++---- .../structured-agent-session-event-sink.ts | 31 ++++--- .../structured-agent-session-eviction.test.ts | 9 +- .../structured-agent-session-host-delivery.ts | 7 +- .../structured-agent-session-host-lifetime.ts | 20 ++--- ...ctured-agent-session-host-runtime-state.ts | 16 +--- .../structured-agent-session-host.ts | 9 +- ...tructured-agent-session-idle-sweep.test.ts | 10 ++- .../structured-agent-session-idle-sweep.ts | 11 ++- ...ctured-agent-session-lease-renewer.test.ts | 20 +++-- .../structured-agent-session-lease-renewer.ts | 20 +++-- ...tured-agent-session-logger-test-support.ts | 8 ++ .../structured-agent-session-logger.ts | 16 ++-- ...nt-session-processless-reservation.test.ts | 1 - ...ructured-agent-session-provider-started.ts | 7 +- ...tructured-agent-session-queued-messages.ts | 15 +++- .../structured-agent-session-queued-wiring.ts | 9 +- ...uctured-agent-session-read-restore.test.ts | 4 +- ...ed-agent-session-readable-restorer.test.ts | 4 +- ...agent-session-recovered-turn-clock.test.ts | 4 +- ...ured-agent-session-resolved-append.test.ts | 9 +- ...ured-agent-session-restart-restore.test.ts | 4 +- ...ent-session-restore-without-import.test.ts | 10 ++- ...red-agent-session-send-idempotency.test.ts | 2 + ...agent-session-startup-failure-exit.test.ts | 2 + ...-session-startup-reconcile-failure.test.ts | 44 +++++----- ...ructured-agent-session-status-feed.test.ts | 3 +- .../structured-agent-session-status-feed.ts | 10 +-- ...red-agent-session-subagent-recency.test.ts | 3 +- ...ured-agent-session-unexpected-exit.test.ts | 11 ++- ...tructured-agent-session-unexpected-exit.ts | 34 +++++-- src/main/orcad/orcad-entry.ts | 2 + src/main/orcad/orcad-observability.test.ts | 88 +++++++++++++++++++ src/main/orcad/orcad-observability.ts | 11 +++ ...ured-agent-session-failure-logging.test.ts | 80 +++++++++++++++++ ...x-child-approval-activity-row.unit.test.ts | 3 +- 64 files changed, 554 insertions(+), 210 deletions(-) create mode 100644 src/main/orcad/orcad-observability.test.ts create mode 100644 src/main/orcad/orcad-observability.ts diff --git a/src/main/claude/claude-api-retry-row.test.ts b/src/main/claude/claude-api-retry-row.test.ts index a1c8da60cb7d..843fbb281d3d 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-context-usage-restart.test.ts b/src/main/claude/claude-context-usage-restart.test.ts index 4359799272e4..be2c1569eadf 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 ed76a788c186..d98ef7583df5 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 7d198c5a5de5..082ed4c12ce7 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 c8a9e014950c..db3b75ecf325 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 1b03881e471b..0cf7b30368a7 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 a3a47c8bea8a..4cae8b465e99 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 d89bd1feb094..d90e5538964e 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 ef6c067db9ea..c4b848eee44c 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 34ff506ee037..2bcffcfb49fe 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-structured-journal-goal-resume.test.ts b/src/main/codex/codex-structured-journal-goal-resume.test.ts index 6df0ba015757..9edeb0043322 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 4f8276005770..aa455403ea78 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 21ae85af6431..a99b815bff48 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 344d99e03b4d..6f00bdbe2b85 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 f9714bbe0625..07115c4b4e77 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 676d9054ed70..523dd1894f83 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 7011cfa0302f..da24f836d1a7 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/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 aa0ae9e29e87..7a9f758858af 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 11505b6c2045..92c52f88a68e 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-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index 2dd2b5229aa7..499c5f3ff48b 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 f9d479add4ce..ebf6dcce5ee0 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 @@ -88,8 +88,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { cause: 'host-stop' }), closeConversation, - onError: (sessionId, error) => - deps().logger.warn('an idle sweep step failed', { scope: 'idle-sweep', sessionId, error }), + logger: deps().logger, ...deps().idleSweep }) 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 3557594b7513..ec4c76e26ba2 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 70fe538ce202..b634b74c2120 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-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 6677a50b4470..b39790e6b2af 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 72caac40844d..8ec0768cc520 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 9740bac146ef..7febbe95baf9 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 @@ -133,14 +134,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 f7a50627370e..106bf9155928 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 @@ -24,10 +24,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 @@ -58,7 +61,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)) } @@ -68,6 +80,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 2d0766a4bce8..508893d67f44 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 ed8ea7b3b462..2993ce35a4e3 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 @@ -16,6 +16,7 @@ import { } 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', @@ -92,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 }) @@ -111,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 }) @@ -128,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() @@ -142,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), { @@ -167,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() @@ -183,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)) @@ -231,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, @@ -267,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, @@ -304,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, @@ -334,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, @@ -369,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, @@ -400,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) @@ -416,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, @@ -445,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) @@ -462,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' }) @@ -491,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, @@ -515,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, @@ -535,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', @@ -552,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 121fb865db4a..18003e72369f 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 d7d6111659de..236dc7bc54bc 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 @@ -11,6 +11,7 @@ import { } 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[] = [] @@ -46,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) } @@ -148,6 +151,7 @@ describe('rows the provider emits while closing', () => { await evictStructuredAgentSession({ sessionId, + logger: recordingStructuredAgentSessionLogger().logger, eventSink: sink, adapter: { closeSession: async () => { @@ -229,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-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 3d625b0ccfae..f2f885e07638 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,12 +78,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { deps.store.getRecord(sessionId), sessions.get(sessionId)?.journal ), - onError: (sessionId, error) => - deps.logger.warn('delivering a queued message failed', { - scope: 'delivery-loop', - sessionId, - error - }), + logger: deps.logger, record: (sessionId) => deps.store.getRecord(sessionId), readChildWork: input.clientDelivery.readChildWork, flushStreamedEvents: input.flushStreamedEvents, 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 370c3ddda4eb..a2f38c2e6bb2 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 @@ -117,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. @@ -158,19 +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.logger.warn("settling a closed agent's work failed", { - scope: 'close-settlement', - 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.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts index cd80ed14abf5..1df3bc349097 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,12 +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.logger.warn('renewing a chat lease failed', { - scope: 'lease-renewal', - sessionId, - error - }) + logger: deps.logger }) } @@ -72,12 +67,9 @@ export class StructuredAgentSessionHostRuntimeState { mintEventSink(sessionId: string): DeferredStructuredAgentSessionEventSink { const minted: DeferredStructuredAgentSessionEventSink = createDeferredStructuredAgentSessionEventSink({ - onError: (error) => { - this.deps.logger.error('writing provider events to the chat journal failed', { - scope: 'journal-event-sink', - 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.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 91c89d7d1b1a..57cc881f0e3a 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,7 +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 { logSessionFailure, withNeverThrowingLogger } from './structured-agent-session-logger' +import * as sessionLogger from './structured-agent-session-logger' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' export class StructuredAgentSessionHost { @@ -69,7 +69,7 @@ export class StructuredAgentSessionHost { this.subscribers.publish(sessionId, journal) this.conversationDelivery.afterCommit(sessionId, journal) }, - onDeliveryError: logSessionFailure(() => this.deps.logger, 'journal-delivery', 'not delivered'), + logger: sessionLogger.deferredStructuredAgentSessionLogger(() => this.deps.logger), onOpened: (sessionId) => this.queued.drain.schedule(sessionId), now: () => this.now() }) @@ -103,7 +103,7 @@ export class StructuredAgentSessionHost { constructor(readonly deps: StructuredAgentSessionHostDeps) { // Every collaborator reads this copy, so a logger that throws cannot fail what it reports. - this.deps = deps = withNeverThrowingLogger(deps) + this.deps = deps = sessionLogger.withNeverThrowingLogger(deps) this.backgroundTasks = new StructuredAgentSessionBackgroundTaskChannel( deps, this.sessions, @@ -160,8 +160,7 @@ export class StructuredAgentSessionHost { ), publishStatus: this.clientDelivery.publishStatusAndSettlement, serialize: (sessionId, task) => this.tasks.trackAttach(this.serialize(sessionId, task)), - now: () => this.now(), - onBarrierError: logSessionFailure(() => deps.logger, 'lifecycle-barrier', 'barrier failed') + 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 cad1d17d94f5..8ff4cdbeb426 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 14cc0e56030d..e9b0e1b45c60 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-lease-renewer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts index 9399e4d0242b..c3eb1e324e9f 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 21d790202709..7888d4867e23 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-logger-test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-logger-test-support.ts index 8b897a5aaa6f..5a63f274f025 100644 --- 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 @@ -25,3 +25,11 @@ export function recordingStructuredAgentSessionLogger(): { scopes: () => entries.map((entry) => entry.fields.scope) } } + +/** 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.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-logger.ts index de00a6e95925..2a7ad0e09d7a 100644 --- 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 @@ -56,14 +56,14 @@ export function withNeverThrowingLogger StructuredAgentSessionLogger, - scope: string, - message: string -): (sessionId: string, error: unknown) => void { - return (sessionId, error) => logger().warn(message, { scope, sessionId, error }) +/** 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. */ 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 b3880fb85f12..c45ceac238c8 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 @@ -313,7 +313,6 @@ describe('processless structured session reservation', () => { await expect( performAttach({ - logger: createStructuredAgentSessionLogger(), ...input, authority: { ...input.authority, 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 8f3c0390aaa1..acc2e6488c3d 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-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index b6d9d2a90beb..856134f04a9b 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-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts index 27f6ad959294..f3861f97166a 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,12 +34,8 @@ export function wireStructuredAgentSessionQueuedMessages( conversationFence: (sessionId) => structuredAgentSessionConversationFence(context().deps.store, sessionId), wakeDelivery: (sessionId) => context().wakeDelivery(sessionId), - onError: (sessionId, error) => - context().deps.logger.warn('draining queued messages failed', { - scope: 'queued-drain', - 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, 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 83238e95d74f..945f2cefbad2 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 09cab618ab9a..8f110c3e69a2 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 0849fa5aa824..a277d9a3d4ca 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 @@ -131,7 +131,7 @@ async function sessionWithRunningTurn() { function settleDeadGeneration( journal: AgentSessionJournal, verdict: StructuredAgentSessionTurnVerdict -): Promise { +): ReturnType { return settleStructuredAgentSessionDeadGeneration({ journal, sessionId: SESSION, @@ -162,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-resolved-append.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts index d3c15de933c7..4b06198e373b 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-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index 4da648576ed6..441d4e80626d 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-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index 96fb0ca9bd08..a2efb20f4592 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 @@ -39,6 +39,7 @@ import type { StructuredAgentSessionLifetimeContext } from './structured-agent-s 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(), @@ -217,7 +218,7 @@ type LifetimeHost = Parameters undefined, - onDeliveryError: () => undefined, + logger: recordingStructuredAgentSessionLogger().logger, now: () => clock }) } @@ -229,7 +230,12 @@ 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 deps = { + store, + adapter: {}, + journalDatabase: openTestJournalHostDatabase(root), + logger: recordingStructuredAgentSessionLogger().logger + } await restoreStructuredAgentSessionsOnRestart({ openDeps: deps, records: sessionIds.map(recordFor), 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 7cdbbaaa5c29..48ceef370e78 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 @@ -10,6 +10,7 @@ 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() @@ -57,6 +58,7 @@ describe('structured send idempotency', () => { const result = await performSend( { + logger: recordingStructuredAgentSessionLogger().logger, sessionId: 'session-1', journal, fence: 2, 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 3c4096be1380..8235d0d67054 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 a4470f81a457..b71022b92c70 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 @@ -82,12 +82,12 @@ async function relaunch( } const store = await openTestAgentSessionRecordStore(relaunched) // The lease-reconcile entries the host logs, by the failure each reports. - const onLeaseReconcileFailure = vi.fn() + const leaseReconcileLogged = vi.fn() const host = new StructuredAgentSessionHost({ logger: { warn: (_message, fields) => { if (fields.scope === 'lease-reconcile') { - onLeaseReconcileFailure(fields.error) + leaseReconcileLogged(fields.error) } }, error: () => undefined @@ -104,26 +104,26 @@ async function relaunch( 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') @@ -136,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) @@ -150,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' @@ -160,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() @@ -168,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 () => { @@ -190,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' })) @@ -215,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 logger throws', async (_step, read) => { - const { host, onLeaseReconcileFailure } = await relaunch() + 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(() => {}) @@ -233,7 +233,7 @@ it.each([ try { await expect(read(host)).resolves.toBeUndefined() - expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(leaseReconcileLogged).toHaveBeenCalledOnce() expect(warn).toHaveBeenCalledWith( expect.stringContaining('chat lease bookkeeping for a read failed'), expect.objectContaining({ error: IO_ERROR, loggerError: sinkError }) 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 ed2bea3377ed..579be82e0c96 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 @@ -24,6 +24,7 @@ import { 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 = { @@ -548,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 19ad9535ac9c..ffb6767a321b 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,7 +28,10 @@ import { type StructuredAgentSessionStatusState } from './structured-agent-session-status-journal-projection' import { structuredStatusSummariesEqual } from './structured-agent-session-status-summary-equality' -import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import { + deferredStructuredAgentSessionLogger, + type StructuredAgentSessionLogger +} from './structured-agent-session-logger' import { StructuredAgentSessionStatusOwnership, type StructuredAgentSessionStatusSink @@ -86,10 +89,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: { sessions: args.sessions, getRecord: (sessionId) => args.deps().store.getRecord(sessionId), now: args.now, - logger: { - warn: (message, fields) => args.deps().logger.warn(message, fields), - error: (message, fields) => args.deps().logger.error(message, fields) - }, + 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. 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 1d5a3150c10d..d47efcd4cad6 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 @@ -21,6 +21,7 @@ import { createDeferredStructuredAgentSessionEventSink } from './structured-agen 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' @@ -83,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-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index 2cb38a466345..b2ddb882207b 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 3f758f81607b..419f7005c40c 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/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 6c0e9bbad227..d753c4caceb2 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -122,6 +122,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') + installOrcadObservability((handler) => getAppEnvironment().onWillQuit(handler)) 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 000000000000..4b5dbc728223 --- /dev/null +++ b/src/main/orcad/orcad-observability.test.ts @@ -0,0 +1,88 @@ +import { mkdtempSync, readFileSync, rmSync } 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 }) + }) + + it('writes a structured chat failure under the data root, flushed by quit', () => { + const quitHandlers: (() => void)[] = [] + installOrcadObservability((handler) => quitHandlers.push(handler)) + + createStructuredAgentSessionLogger().warn('settling a late dispatch failed', { + scope: 'late-settlement', + sessionId: 'session-1', + error: new Error('disk full') + }) + for (const handler of quitHandlers) { + handler() + } + + const records = readFileSync(join(dataRoot, 'logs', 'main.trace.ndjson'), 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(records).toContainEqual( + expect.objectContaining({ + name: 'agentSession.late-settlement', + attributes: expect.objectContaining({ sessionId: 'session-1' }), + exit: expect.objectContaining({ _tag: 'Failure' }) + }) + ) + }) +}) + +// 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', () => { + const entry = readFileSync(join(import.meta.dirname, 'orcad-entry.ts'), 'utf8') + const install = entry.indexOf( + 'installOrcadObservability((handler) => getAppEnvironment().onWillQuit(handler))' + ) + expect(install).toBeGreaterThan(-1) + expect(install).toBeLessThan(entry.indexOf('new OrcaRuntimeService(')) +}) diff --git a/src/main/orcad/orcad-observability.ts b/src/main/orcad/orcad-observability.ts new file mode 100644 index 000000000000..697b35cc5495 --- /dev/null +++ b/src/main/orcad/orcad-observability.ts @@ -0,0 +1,11 @@ +// orcad's local trace file: the desktop's sink, consent gates and file name, under orcad's own data +// root (`/logs/main.trace.ndjson`), so a headless host's reported failures reach a file +// and not only the supervisor's stderr. + +import { initObservability, shutdownObservability } from '../observability' + +/** Installed before the runtime, so its first span lands; closed on quit, after runtime teardown. */ +export function installOrcadObservability(onWillQuit: (handler: () => void) => void): void { + initObservability() + onWillQuit(() => void shutdownObservability()) +} diff --git a/src/main/runtime/structured-agent-session-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts index 97fa5bf81db8..9a8d9edb96f1 100644 --- a/src/main/runtime/structured-agent-session-failure-logging.test.ts +++ b/src/main/runtime/structured-agent-session-failure-logging.test.ts @@ -9,6 +9,18 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-t 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 @@ -149,6 +161,74 @@ describe('failures the desktop host used to drop', () => { }) }) +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) 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 a9d1370542c0..27f00644ec1b 100644 --- a/tests/e2e/codex-child-approval-activity-row.unit.test.ts +++ b/tests/e2e/codex-child-approval-activity-row.unit.test.ts @@ -72,6 +72,7 @@ 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' @@ -153,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[] = [] From 7ba42910c4690636249daf411ddc043b6090af59 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:05:10 -0700 Subject: [PATCH 3/8] fix(native-chat): keep repeated chat failures from flooding the trace file, and record their causes - The production structured-chat logger writes a repeated failure (same level, scope, session, message and error text) once per 5 minutes, carrying how many repeats it swallowed; the tracked set is capped at 256. - Trace entries now carry the error's code (and SQLite errcode) and up to three causes by name and message. - A chat read whose conversation will not open is logged through the host's logger (open-for-read), and so are the runtime's chat-tab bookkeeping failures that already hold the host. - orcad writes its own orcad.trace.ndjson, closes it after every quit handler, and flushes it on process exit; a trace file that cannot be opened leaves tracing off instead of stopping the app or orcad. - Tests: the desktop wiring test proves the logger reaches the trace sink, and the privacy tests read every level the logger received. --- docs/reference/orcad-operations.md | 7 +- .../journal-open-failure.test.ts | 49 +++--- .../journal-open-failure.ts | 23 ++- ...red-agent-session-conversation-lifetime.ts | 5 +- ...uctured-agent-session-host-test-harness.ts | 8 +- .../structured-agent-session-log-repeats.ts | 54 +++++++ ...tured-agent-session-logger-test-support.ts | 29 +++- .../structured-agent-session-logger.test.ts | 142 ++++++++++++++++++ .../structured-agent-session-logger.ts | 78 ++++++++-- .../structured-agent-session-options-read.ts | 2 +- ...ssion-restart-interruption-test-harness.ts | 7 +- ...ed-agent-session-restart-ownership.test.ts | 19 ++- ...red-agent-session-restart-teardown.test.ts | 11 +- ...ent-session-restore-without-import.test.ts | 14 +- src/main/observability/index.ts | 27 +++- src/main/observability/logs-directory.ts | 5 + src/main/orcad/orcad-entry.ts | 10 +- src/main/orcad/orcad-observability.test.ts | 66 ++++++-- src/main/orcad/orcad-observability.ts | 24 ++- ...time-close-structured-agent-session-tab.ts | 6 +- .../runtime/orca-runtime-get-worktree-ps.ts | 2 +- ...tore-structured-agent-session-tabs-once.ts | 6 +- ...time-structured-status-sink-wiring.test.ts | 53 +++++-- .../structured-agent-session-at-rest.test.ts | 7 +- .../methods/structured-chat-tab-table.test.ts | 8 +- .../structured-agent-session-close.test.ts | 13 +- .../runtime/structured-agent-session-close.ts | 7 +- ...ed-agent-session-startup-reconcile.test.ts | 21 ++- ...-agent-session-startup-tab-restore.test.ts | 13 +- ...structured-agent-session-tab-index-seed.ts | 5 +- .../structured-session-worktree-teardown.ts | 10 +- 31 files changed, 589 insertions(+), 142 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-log-repeats.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-logger.test.ts diff --git a/docs/reference/orcad-operations.md b/docs/reference/orcad-operations.md index 63f03777c176..adc18586c213 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/native-chat/agent-session-journal/journal-open-failure.test.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts index 2fb343d2d508..4d888a4fb403 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 @@ -120,30 +121,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) @@ -152,17 +162,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' + ]) }) }) @@ -185,13 +199,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 5e014c279cb1..d154e1b29e69 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-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index ebf6dcce5ee0..491021dc1c1c 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) => { 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 8d6bee128bb9..c7667531110f 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,7 +29,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const journals = createTrackedJournalOpener() @@ -63,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 @@ -153,8 +154,9 @@ beforeEach(async () => { setOption = vi.fn(async () => undefined) store = await openTestAgentSessionRecordStore(root) recoveryCapsule = new TrackedTestRecoveryCapsule(root) + log = recordingProductionStructuredAgentSessionLogger() host = new StructuredAgentSessionHost({ - logger: createStructuredAgentSessionLogger(), + logger: log.logger, store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), @@ -202,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-log-repeats.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-log-repeats.ts new file mode 100644 index 000000000000..d805a2191d2d --- /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 index 5a63f274f025..f4413c4d11bf 100644 --- 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 @@ -1,6 +1,7 @@ -import type { - StructuredAgentSessionLogFields, - StructuredAgentSessionLogger +import { + createStructuredAgentSessionLogger, + type StructuredAgentSessionLogFields, + type StructuredAgentSessionLogger } from './structured-agent-session-logger' export type RecordedStructuredAgentSessionLog = { @@ -26,6 +27,28 @@ export function recordingStructuredAgentSessionLogger(): { } } +/** 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 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 000000000000..5b9316e18c2f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-logger.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { _resetTracerForTests, setActiveSink } from '../../observability/tracer' +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('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 index 2a7ad0e09d7a..0ca4f20bac20 100644 --- 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 @@ -2,10 +2,11 @@ // // 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 app's local trace file (`/logs/main.trace.ndjson`, collected by the diagnostic -// bundle) and to the console, which is stderr under a supervised headless host. +// 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 { 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. */ @@ -66,24 +67,75 @@ export function deferredStructuredAgentSessionLogger( } } -/** The production logger: a failed span in the local trace file, plus the console. */ -export function createStructuredAgentSessionLogger(): StructuredAgentSessionLogger { +/** 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 + // The error's own text is in the key: a failure that changes is news, as the read door's is. + const { error: keyError } = fields + const errorText = keyError instanceof Error ? keyError.message : String(keyError) + const suppressed = repeats.admit( + JSON.stringify([level, fields.scope, fields.sessionId ?? null, message, errorText]) + ) + if (suppressed === null) { + return + } + const entry = suppressed > 0 ? { ...fields, suppressed } : fields + const { scope, error, ...rest } = entry const span = startSpan(`agentSession.${scope}`, { - attributes: { - level, - message, - ...rest, - // An Error's own fields do not enumerate; the span's failure cause carries its stack. - ...(error !== undefined && !(error instanceof Error) ? { error } : {}) - } + attributes: { level, message, ...rest, ...errorAttributes(error) } }) span.fail(error instanceof Error ? error : message) const print = level === 'error' ? console.error : console.warn - print(`[agent-session] ${scope}: ${message}`, fields) + print(`[agent-session] ${scope}: ${message}`, entry) } 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 = errorCode(error, 'errcode') + return { + ...(code !== undefined ? { errorCode: code } : {}), + ...(sqliteCode !== undefined ? { errorErrcode: sqliteCode } : {}), + ...(causes.length > 0 ? { errorCause: causes } : {}) + } +} + +function errorCode(error: Error, key: 'code' | 'errcode' = 'code'): string | number | undefined { + const value: unknown = Reflect.get(error, key) + 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-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 106998ccfed5..e17d5ddf074a 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-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 16cf2704f9a8..94746daf3ec6 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,7 +34,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' +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. */ @@ -125,8 +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: createStructuredAgentSessionLogger(), + logger: log.logger, store, adapter: { ...adapter(), @@ -156,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-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index a810f0db1350..a46551b4500e 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 @@ -54,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 ( @@ -73,6 +73,8 @@ it('reports a failed attribution note without an installed error sink or private '[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() @@ -320,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([]) @@ -337,14 +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 = '[agent-session] restart-offer-withdraw: withdrawing a restart offer failed' await vi.waitFor(() => - expect(warning).toHaveBeenLastCalledWith(withdrawing, { - scope: 'restart-offer-withdraw', - sessionId: SESSION + expect(log.entries.at(-1)).toEqual({ + level: 'warn', + message: 'withdrawing a restart offer failed', + fields: { scope: 'restart-offer-withdraw', sessionId: SESSION } }) ) - expect(warning.mock.calls.filter(([message]) => message !== withdrawing)).toHaveLength(5) + // 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() }) @@ -462,6 +465,8 @@ it('logs teardown capsule publication failure and still releases the provider', { scope: 'teardown-recovery-capsule' } ) 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-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts index efd3ccc9e6c9..3d920db766a4 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 @@ -22,7 +22,7 @@ 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(() => { @@ -43,6 +43,15 @@ it.each(['beginTeardown', 'captureBeforeStop', 'recordMarkers'] as const)( }[method] ) 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 a2efb20f4592..51ab878e2c82 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 @@ -230,11 +230,12 @@ async function restore(sessionIds: readonly string[]) { getRecord: (sessionId: string) => recordFor(sessionId), listRecords: () => sessionIds.map(recordFor) } as unknown as AgentSessionRecordStore + const log = recordingStructuredAgentSessionLogger() const deps = { store, adapter: {}, journalDatabase: openTestJournalHostDatabase(root), - logger: recordingStructuredAgentSessionLogger().logger + logger: log.logger } await restoreStructuredAgentSessionsOnRestart({ openDeps: deps, @@ -261,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 { @@ -434,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) @@ -450,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/observability/index.ts b/src/main/observability/index.ts index 74728a2d81b8..f620fb9a77dd 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 20ad104af740..7b244bec43eb 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 d753c4caceb2..854b4dfb723c 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 = () => {} + } ) } @@ -123,7 +129,7 @@ async function startOrcadRuntime( await import('../ipc/pty') const { getAppEnvironment } = await import('../../shared/app-environment') const { installOrcadObservability } = await import('./orcad-observability') - installOrcadObservability((handler) => getAppEnvironment().onWillQuit(handler)) + 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 index 4b5dbc728223..03b493425230 100644 --- a/src/main/orcad/orcad-observability.test.ts +++ b/src/main/orcad/orcad-observability.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +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' @@ -49,40 +49,74 @@ describe('orcad trace file', () => { rmSync(dataRoot, { recursive: true, force: true }) }) - it('writes a structured chat failure under the data root, flushed by quit', () => { - const quitHandlers: (() => void)[] = [] - installOrcadObservability((handler) => quitHandlers.push(handler)) + 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') }) - for (const handler of quitHandlers) { - handler() - } - const records = readFileSync(join(dataRoot, 'logs', 'main.trace.ndjson'), 'utf8') - .trim() - .split('\n') - .map((line) => JSON.parse(line)) - expect(records).toContainEqual( + 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', () => { +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( - 'installOrcadObservability((handler) => getAppEnvironment().onWillQuit(handler))' - ) + 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 index 697b35cc5495..4200180a2c9c 100644 --- a/src/main/orcad/orcad-observability.ts +++ b/src/main/orcad/orcad-observability.ts @@ -1,11 +1,19 @@ -// orcad's local trace file: the desktop's sink, consent gates and file name, under orcad's own data -// root (`/logs/main.trace.ndjson`), so a headless host's reported failures reach a file -// and not only the supervisor's stderr. +// 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 { initObservability, shutdownObservability } from '../observability' +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; closed on quit, after runtime teardown. */ -export function installOrcadObservability(onWillQuit: (handler: () => void) => void): void { - initObservability() - onWillQuit(() => void shutdownObservability()) +/** 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/orca-runtime-close-structured-agent-session-tab.ts b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts index 3b3510916ba2..eae4b7b7e326 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 ddeb59f126d4..b25ce381def3 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -143,7 +143,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis stateDirectory: getProfileUserDataPath(), hostId: LOCAL_EXECUTION_HOST_ID, claimKeyId: this.agentSessionClaimSigner.keyId, - // The desktop's trace file, or stderr under a supervised headless host. + // 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. 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 f8845221e66a..61c2e0b3605f 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 09afcf4b67ca..9936c954b5b1 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' - -const installed = vi.hoisted(() => ({ deps: null as Record | null })) +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(() => { + 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,17 +104,30 @@ 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. + // 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.deps = null + installed.logger = null + const push = vi.fn() + setActiveSink({ push, flush: () => {}, close: () => {} }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) const runtime = new OrcaRuntimeService() await runtime.ensureStructuredAgentSessionHost() - - expect(installed.deps?.['logger']).toMatchObject({ - warn: expect.any(Function), - error: expect.any(Function) + 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 () => { 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 9cbad804cd50..b846be96dd22 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-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index ef21f02ffd92..2321bbc2a2a7 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 @@ -503,8 +503,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 }) ) }) }) @@ -522,8 +522,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/structured-agent-session-close.test.ts b/src/main/runtime/structured-agent-session-close.test.ts index 948bb01cce2d..500a6cd1bebd 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 3ee8f265c4df..16bf8cce71d7 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-startup-reconcile.test.ts b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts index a6417d3b1499..43d457790e31 100644 --- a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts +++ b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts @@ -127,11 +127,11 @@ it('finishes startup when the reconcile cannot write, and reports it', async () // 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() + 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 @@ -141,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 6f57a1f498f9..ac6df4c4b2b3 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 @@ -413,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 @@ -422,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 188a419f31e2..3e2245148a26 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-session-worktree-teardown.ts b/src/main/runtime/structured-session-worktree-teardown.ts index 3f8ab31b3ec7..047b4a216445 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 - ) + }) } } From a104a34dabccf5708d9a660b8015d8305db968af Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:37:05 -0700 Subject: [PATCH 4/8] fix(native-chat): log a created chat's tab-publication and launch-prompt failures through the host's logger --- .../agent-launch-structured-prompt.test.ts | 22 +++++++--- .../methods/agent-launch-structured-prompt.ts | 6 ++- ...ent-session-create-tab-publication.test.ts | 41 +++++++++++++++++++ .../structured-agent-session-create.ts | 6 ++- ...ed-agent-session-precommit-refusal.test.ts | 7 +++- ...ructured-agent-session-rpc.test-fixture.ts | 3 ++ 6 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts 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 4d919df556ed..1b5910519361 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 11762137ba2f..00c779fffe0b 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-create-tab-publication.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts new file mode 100644 index 000000000000..cf708f374916 --- /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 0c3b7b087133..1d0899a014df 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-precommit-refusal.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts index f34ecb5d6cd8..17aadf32a801 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 6c9be24ca4b2..57606c8c4562 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 @@ -20,6 +20,7 @@ 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) @@ -208,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 } From e445a58dfcb743a7776b0c6753b9672cbc3d6aa0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:05:13 -0700 Subject: [PATCH 5/8] fix(native-chat): key a repeated chat failure on everything its entry writes The repeat suppression keyed on the message and the error's text, so two refusals with the same code but different causes, a plain error and a refusal of one code, or two object-valued errors shared a key and the second was swallowed for five minutes. The key is now the entry's whole written content (fields, code, errcode, refusal reason, cause chain, a stable rendering of a non-error value) plus the error's name and message; a refusal's reason is also written. --- .../structured-agent-session-logger.test.ts | 61 +++++++++++++++++++ .../structured-agent-session-logger.ts | 46 +++++++++++--- 2 files changed, 99 insertions(+), 8 deletions(-) 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 index 5b9316e18c2f..3823b96ff9c7 100644 --- 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 @@ -1,5 +1,10 @@ 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, @@ -83,6 +88,62 @@ describe('a failure that repeats', () => { ]) }) + 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' }), + { 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 the same code as a plain error and as a refusal as two 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()) + } + + expect(written()).toHaveLength(2) + }) + it('tracks a bounded number of repeating entries', () => { const repeats = createStructuredAgentSessionLogRepeats({ now: () => clock, maxTracked: 2 }) expect(repeats.admit('a')).toBe(0) 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 index 0ca4f20bac20..da58e25d9799 100644 --- 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 @@ -5,6 +5,7 @@ // 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' @@ -76,23 +77,28 @@ export function createStructuredAgentSessionLogger(options?: { const write = (level: LogLevel) => (message: string, fields: StructuredAgentSessionLogFields): void => { - // The error's own text is in the key: a failure that changes is news, as the read door's is. - const { error: keyError } = fields - const errorText = keyError instanceof Error ? keyError.message : String(keyError) + 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( - JSON.stringify([level, fields.scope, fields.sessionId ?? null, message, errorText]) + stableJson([ + scope, + attributes, + error instanceof Error ? `${error.name}: ${error.message}` : null + ]) ) if (suppressed === null) { return } - const entry = suppressed > 0 ? { ...fields, suppressed } : fields - const { scope, error, ...rest } = entry const span = startSpan(`agentSession.${scope}`, { - attributes: { level, message, ...rest, ...errorAttributes(error) } + 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}`, entry) + print( + `[agent-session] ${scope}: ${message}`, + suppressed > 0 ? { ...fields, suppressed } : fields + ) } return neverThrowingStructuredAgentSessionLogger({ warn: write('warn'), error: write('error') }) } @@ -123,13 +129,37 @@ function errorAttributes(error: unknown): Record { const code = errorCode(error) // node:sqlite's `code` is one generic value; `errcode` tells SQLITE_BUSY from SQLITE_FULL. const sqliteCode = errorCode(error, 'errcode') + // A refusal's message is its bare code; its reason is wire-safe and tells refusals apart. + const details: unknown = isAgentSessionRefusalError(error) ? error.refusal.details : undefined + const reason: unknown = + typeof details === 'object' && details !== null ? Reflect.get(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, key: 'code' | 'errcode' = 'code'): string | number | undefined { const value: unknown = Reflect.get(error, key) return typeof value === 'string' || typeof value === 'number' ? value : undefined From c1f1cab6a59dfebf14def85b6234a7952b0fca16 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:06:02 -0700 Subject: [PATCH 6/8] test(native-chat): pin that an error's name keeps two repeated failures apart --- .../structured-agent-session-logger.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 index 3823b96ff9c7..88e35950c707 100644 --- 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 @@ -128,7 +128,7 @@ describe('a failure that repeats', () => { expect(written()[0]?.attributes).toMatchObject({ refusalReason: 'journalUnavailable' }) }) - it('writes the same code as a plain error and as a refusal as two failures', () => { + 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 }) @@ -139,9 +139,14 @@ describe('a failure that repeats', () => { for (let tick = 0; tick < 3; tick += 1) { renew(unreadableRecord()) renew(reconciling()) + renew(new TypeError('execution_owner_reconciling')) } - expect(written()).toHaveLength(2) + 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', () => { From d1627412ab06a6df0982ddaddc254d075f56ab75 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:09:25 -0700 Subject: [PATCH 7/8] test(native-chat): build the refusal in the repeat-key test as the wire does --- .../structured-agent-session-logger.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index 88e35950c707..0cacbda8587e 100644 --- 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 @@ -93,7 +93,11 @@ describe('a failure that repeats', () => { // 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' }), + refuse( + 'agent_session_journal_unreadable', + { reason: 'journalUnavailable' }, + 'agent_session_journal_unreadable' + ), { cause } ) const report = (error: unknown): void => From 3a54c6dd5429f313a659923b224993d1ed2d9739 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:25:45 -0700 Subject: [PATCH 8/8] fix(native-chat): read an error's code and a refusal's reason by narrowing, not Reflect.get --- .../structured-agent-session-logger.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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 index da58e25d9799..1244e9745327 100644 --- 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 @@ -128,11 +128,10 @@ function errorAttributes(error: unknown): Record { } const code = errorCode(error) // node:sqlite's `code` is one generic value; `errcode` tells SQLITE_BUSY from SQLITE_FULL. - const sqliteCode = errorCode(error, 'errcode') + 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: unknown = isAgentSessionRefusalError(error) ? error.refusal.details : undefined - const reason: unknown = - typeof details === 'object' && details !== null ? Reflect.get(details, 'reason') : undefined + 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 } : {}), @@ -160,8 +159,11 @@ function stableJson(value: unknown): string { } } -function errorCode(error: Error, key: 'code' | 'errcode' = 'code'): string | number | undefined { - const value: unknown = Reflect.get(error, key) +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 }