From c6cfcc034eb7b25b079e816e31fc49ef5d7d81ee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:21:50 -0700 Subject: [PATCH] refactor(native-chat): structured chat failures always reach the diagnostics log (#24312) * 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. * 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. * 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. * fix(native-chat): log a created chat's tab-publication and launch-prompt failures through the host's logger * 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. * test(native-chat): pin that an error's name keeps two repeated failures apart * test(native-chat): build the refusal in the repeat-key test as the wire does * fix(native-chat): read an error's code and a refusal's reason by narrowing, not Reflect.get --- docs/reference/orcad-operations.md | 7 +- .../first-work-branch-rename.test.ts | 3 + .../claude-api-retry-idle-sweep.test.ts | 2 + src/main/claude/claude-api-retry-row.test.ts | 3 +- .../claude-background-wake-retention.test.ts | 2 + .../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 +- .../codex-provider-retry-idle-sweep.test.ts | 2 + ...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 +- .../codex-structured-question-order.test.ts | 2 + .../journal-open-failure.test.ts | 49 ++- .../journal-open-failure.ts | 23 +- .../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 + ...red-agent-session-at-rest-commands.test.ts | 2 + .../structured-agent-session-attach-flow.ts | 8 +- ...ured-agent-session-attach-orchestration.ts | 3 +- ...-agent-session-attach-test-conversation.ts | 11 +- ...nt-session-background-task-channel.test.ts | 3 +- .../structured-agent-session-chat-stop.ts | 7 +- ...-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 +- ...t-session-claude-stop-ends-session.test.ts | 20 +- ...ctured-agent-session-close-verdict.test.ts | 2 + ...tured-agent-session-codex-stop-row.test.ts | 2 + ...d-agent-session-conversation-close.test.ts | 10 +- ...red-agent-session-conversation-lifetime.ts | 7 +- ...uctured-agent-session-conversation-open.ts | 16 +- ...ed-agent-session-conversation-stop.test.ts | 2 + ...ctured-agent-session-conversations.test.ts | 19 +- .../structured-agent-session-conversations.ts | 9 +- ...ured-agent-session-crash-mid-start.test.ts | 2 + ...tured-agent-session-crash-turn-end.test.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 | 44 ++- .../structured-agent-session-event-sink.ts | 31 +- .../structured-agent-session-eviction.test.ts | 11 +- .../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 | 24 +- .../structured-agent-session-host-lifetime.ts | 25 +- ...d-agent-session-host-runtime-state.test.ts | 22 +- ...ctured-agent-session-host-runtime-state.ts | 7 +- .../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 | 6 + .../structured-agent-session-host-types.ts | 9 +- .../structured-agent-session-host.test.ts | 3 + .../structured-agent-session-host.ts | 8 +- ...tructured-agent-session-idle-sweep.test.ts | 10 +- .../structured-agent-session-idle-sweep.ts | 11 +- ...ured-agent-session-import-mismatch.test.ts | 2 + ...d-agent-session-journal-corruption.test.ts | 13 +- ...ured-agent-session-late-settlement.test.ts | 2 + ...ctured-agent-session-lease-renewer.test.ts | 20 +- .../structured-agent-session-lease-renewer.ts | 20 +- ...gent-session-legacy-handoff-record.test.ts | 2 + .../structured-agent-session-log-repeats.ts | 54 +++ ...tured-agent-session-logger-test-support.ts | 58 +++ .../structured-agent-session-logger.test.ts | 212 +++++++++++ .../structured-agent-session-logger.ts | 173 +++++++++ ...ssion-main-agent-working-agreement.test.ts | 2 + ...ctured-agent-session-mutation-admission.ts | 12 +- ...ructured-agent-session-mutation-context.ts | 1 + ...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 + .../structured-agent-session-options-read.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 | 6 + ...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 + ...ructured-agent-session-provider-started.ts | 7 +- ...session-queued-message-rig.test-fixture.ts | 2 + ...tructured-agent-session-queued-messages.ts | 15 +- ...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 | 6 +- ...uctured-agent-session-read-restore.test.ts | 4 +- ...ed-agent-session-readable-restorer.test.ts | 4 +- ...agent-session-recovered-turn-clock.test.ts | 6 +- ...tured-agent-session-recovery-exits.test.ts | 2 + ...ctured-agent-session-refusal-retry.test.ts | 2 + ...ured-agent-session-resolved-append.test.ts | 9 +- .../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 | 5 +- ...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 | 29 +- ...uctured-agent-session-restart-reconcile.ts | 18 +- ...ured-agent-session-restart-restore.test.ts | 4 +- ...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 | 26 +- ...ent-session-restore-without-import.test.ts | 24 +- .../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 | 4 + ...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 + ...agent-session-startup-failure-exit.test.ts | 2 + ...-session-startup-reconcile-failure.test.ts | 60 ++-- ...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 | 5 +- .../structured-agent-session-status-feed.ts | 24 +- ...tured-agent-session-status-reentry.test.ts | 2 + ...red-agent-session-subagent-recency.test.ts | 5 +- ...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 + ...ured-agent-session-unexpected-exit.test.ts | 11 +- ...tructured-agent-session-unexpected-exit.ts | 34 +- ...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 +- 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 | 122 +++++++ src/main/orcad/orcad-observability.ts | 19 + ...agent-session-legacy-record-import.test.ts | 14 +- ...ude-structured-session-integration.test.ts | 2 + ...time-close-structured-agent-session-tab.ts | 6 +- .../runtime/orca-runtime-get-worktree-ps.ts | 3 + ...tore-structured-agent-session-tabs-once.ts | 6 +- ...time-structured-status-sink-wiring.test.ts | 52 ++- .../agent-launch-structured-prompt.test.ts | 22 +- .../methods/agent-launch-structured-prompt.ts | 6 +- ...ured-agent-session-adoption-replay.test.ts | 2 + .../structured-agent-session-at-rest.test.ts | 7 +- ...ent-session-create-tab-publication.test.ts | 41 +++ .../structured-agent-session-create.ts | 6 +- .../structured-agent-session-hold.test.ts | 2 + ...ed-agent-session-precommit-refusal.test.ts | 7 +- ...ructured-agent-session-rpc.test-fixture.ts | 5 + .../methods/structured-chat-tab-table.test.ts | 10 +- ...untime-worktree-agent-rows-verdict.test.ts | 2 + ...ree-structured-agent-rows-liveness.test.ts | 2 + .../structured-agent-session-close.test.ts | 13 +- .../runtime/structured-agent-session-close.ts | 7 +- ...-session-codex-turn-end-settlement.test.ts | 2 + ...ctured-agent-session-dispatch-followups.ts | 52 +++ ...ured-agent-session-failure-logging.test.ts | 340 ++++++++++++++++++ ...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 | 67 ++-- ...-agent-session-startup-tab-restore.test.ts | 48 +-- ...structured-agent-session-tab-index-seed.ts | 5 +- .../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 + .../structured-session-worktree-teardown.ts | 10 +- ...x-child-approval-activity-row.unit.test.ts | 5 +- ...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 + 221 files changed, 2435 insertions(+), 546 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-support.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-logger.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-logger.ts create mode 100644 src/main/orcad/orcad-observability.test.ts create mode 100644 src/main/orcad/orcad-observability.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.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/docs/reference/orcad-operations.md b/docs/reference/orcad-operations.md index 63f03777c17..adc18586c21 100644 --- a/docs/reference/orcad-operations.md +++ b/docs/reference/orcad-operations.md @@ -165,7 +165,12 @@ An external supervisor (systemd, launchd, a process manager). orcad conforms to to **stdout**; the supervisor owns capture and rotation. The daemon, being detached, writes its own NDJSON lifecycle log to `/logs/daemon.log` (suppressed by `ORCA_DIAGNOSTICS_DISABLED=1`). Rotation of that file is not implemented — see - [What is not covered](#what-is-not-covered). + [What is not covered](#what-is-not-covered). orcad records every trace span it emits + (git commands, worktree paths, terminal spawns, structured-chat failures and the rest) to + `/logs/orcad.trace.ndjson`, rotated at 10 MB × 10 files, private to its user and + redacted for secret-shaped strings. It stays on the host: a desktop's diagnostics bundle does + not collect it. `ORCA_DIAGNOSTICS_DISABLED=1` turns it off, and a logs folder orcad cannot + open leaves it off with one stderr warning rather than stopping orcad. ### orcad supervising the daemon diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 63b6165ce3a..0e17feb9874 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -63,6 +63,7 @@ import { noUpstreamError, workingEvent } from './first-work-branch-rename-test-harness' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' function makeDeps(overrides: Partial = {}) { return makeBranchRenameDeps(vi.fn, overrides) @@ -117,6 +118,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { } }) const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([ [ 'session', @@ -215,6 +217,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { } const pending: Promise[] = [] const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([['session', { journal, params: { location, provider: 'codex' } }]]), getRecord: () => null, now: () => 1, diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index bac1da60fe7..664f9c135da 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -18,6 +18,7 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const SWEEP_MS = 5 const RETRY_GAP_MS = 10 * 60_000 @@ -77,6 +78,7 @@ beforeEach(async () => { setOption: async () => undefined } host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter, journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/claude/claude-api-retry-row.test.ts b/src/main/claude/claude-api-retry-row.test.ts index a1c8da60cb7..843fbb281d3 100644 --- a/src/main/claude/claude-api-retry-row.test.ts +++ b/src/main/claude/claude-api-retry-row.test.ts @@ -12,6 +12,7 @@ import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/ag import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -54,7 +55,7 @@ async function statusRowsFor(frames: Record[]) { now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-1' }) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind({ journal, fence: 1, publish: vi.fn() }) const translator = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: '1' }) for (const frame of frames) { diff --git a/src/main/claude/claude-background-wake-retention.test.ts b/src/main/claude/claude-background-wake-retention.test.ts index 313f9c5de59..a865e1c1b76 100644 --- a/src/main/claude/claude-background-wake-retention.test.ts +++ b/src/main/claude/claude-background-wake-retention.test.ts @@ -15,6 +15,7 @@ import { backgroundWakeCapture } from './claude-captured-fold-steer-frames.test- import { MOVED_TO_BACKGROUND } from './claude-captured-task-frames.test-fixture' import { hostWithParent, parent, producer } from './claude-child-work-producer-harness.test-fixture' import { PROVIDER_SESSION_ID } from './claude-structured-session-test-support' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) })) @@ -40,6 +41,7 @@ async function wiredSession() { }) } const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([ [ parent.sessionId, diff --git a/src/main/claude/claude-context-usage-restart.test.ts b/src/main/claude/claude-context-usage-restart.test.ts index 4359799272e..be2c1569ead 100644 --- a/src/main/claude/claude-context-usage-restart.test.ts +++ b/src/main/claude/claude-context-usage-restart.test.ts @@ -10,6 +10,7 @@ import { settleStaleStructuredAgentSessionState } from '../native-chat/agent-ses import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { bindClaudeContextUsageCapture } from './claude-context-usage' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const journals = createTrackedJournalOpener() let root: string @@ -112,7 +113,7 @@ async function openJournal(): Promise { /** One acquisition: a fresh sink and translator over the session's journal. */ function acquire(journal: AgentSessionJournal) { - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const translator = createClaudeJournalTranslator({ sink: deferred.sink, coalesceMs: 0 }) deferred.bind({ journal, fence: 1, publish: () => {} }) const answers: ((value: unknown) => void)[] = [] diff --git a/src/main/claude/claude-context-usage-unloaded-turn.test.ts b/src/main/claude/claude-context-usage-unloaded-turn.test.ts index ed76a788c18..d98ef7583df 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -27,6 +27,7 @@ import { userFrame } from './claude-context-usage-test-support' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const SESSION = 'orca-session' const journals = createTrackedJournalOpener() @@ -56,7 +57,7 @@ async function openJournal(): Promise { } function translate(journal: AgentSessionJournal) { - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const translator = createClaudeJournalTranslator({ sink: deferred.sink, coalesceMs: 0 }) deferred.bind({ journal, fence: 1, publish: () => {} }) const settle = async (): Promise => { diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 7d198c5a5de..082ed4c12ce 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -21,6 +21,7 @@ import { readClaudeStructuredSessionOptions } from './claude-structured-session- import type { ClaudeSession } from './claude-structured-session-state' import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' // These drive the real SDK against the scripted fake CLI, so every assertion is // about the environment, argv and frames a real child actually saw. @@ -385,7 +386,7 @@ describe('Claude stream-json connection', () => { now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-1' }) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind({ journal, fence: 1, publish: vi.fn() }) const translator = createClaudeJournalTranslator({ sink: deferred.sink }) let settled = false diff --git a/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts b/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts index c8a9e014950..db3b75ecf32 100644 --- a/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts +++ b/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts @@ -15,6 +15,7 @@ import { import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { blockOf } from './claude-background-task-row-test-support' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' // The frames below are the ones the reported session actually carried: two real // failures that printed THREE red rows whose visible text was the wire opcode, @@ -179,6 +180,7 @@ describe('claude journal translation — background task rows', () => { return appendItem(...args) }) const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(), watermarks: { pauseQueuedOperations: 1, maxQueuedOperations: 4, @@ -252,7 +254,7 @@ describe('claude journal translation — background task rows', () => { it('coalesces an unbound overflow patch and aliased final notification', async () => { const persisted = new Map() - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const translator = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: 'test' @@ -292,7 +294,7 @@ describe('claude journal translation — background task rows', () => { it('reconciles an aliased notification after a parentless overflow patch was persisted', async () => { const persisted = new Map() - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind(persistedTarget(persisted)) const first = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: 'first' }) fillLiveTaskRows(first) @@ -353,7 +355,7 @@ describe('claude journal translation — background task rows', () => { it('resolves a queued restart identity after the sink rebinds', async () => { const persisted = new Map() - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind(persistedTarget(persisted)) const first = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: 'first' }) @@ -371,7 +373,7 @@ describe('claude journal translation — background task rows', () => { first.dispose() await deferred.drained() - const restarted = createDeferredStructuredAgentSessionEventSink() + const restarted = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const second = createClaudeJournalTranslator({ sink: restarted.sink, fallbackIdPrefix: 'second' @@ -397,7 +399,7 @@ describe('claude journal translation — background task rows', () => { it('keeps pending writes from distinct runs when a translator is recreated', async () => { const persisted = new Map() - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const first = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: 'first' }) spawnToolCall(first, 'toolu-first') diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index 1b03881e471..0cf7b30368a 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -23,6 +23,7 @@ import type { ClaudePendingPrompt } from './claude-structured-prompt-replies' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' function sinkState() { const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] @@ -258,7 +259,7 @@ describe('Claude structured journal translation', () => { now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-1' }) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind({ journal, fence: 1, publish: vi.fn() }) let scheduled: (() => void) | null = null const translator = createClaudeJournalTranslator({ @@ -317,7 +318,7 @@ describe('Claude structured journal translation', () => { now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-1' }) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind({ journal, fence: 1, publish: vi.fn() }) const translator = createClaudeJournalTranslator({ sink: deferred.sink }) const approval = prompt({ diff --git a/src/main/claude/claude-structured-provider-fallback.test.ts b/src/main/claude/claude-structured-provider-fallback.test.ts index a3a47c8bea8..4cae8b465e9 100644 --- a/src/main/claude/claude-structured-provider-fallback.test.ts +++ b/src/main/claude/claude-structured-provider-fallback.test.ts @@ -11,6 +11,7 @@ import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/ag import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -80,7 +81,7 @@ describe('Claude provider fallback', () => { now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-1' }) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind({ journal, fence: 1, diff --git a/src/main/claude/claude-structured-session-reading-control.test.ts b/src/main/claude/claude-structured-session-reading-control.test.ts index d89bd1feb09..d90e5538964 100644 --- a/src/main/claude/claude-structured-session-reading-control.test.ts +++ b/src/main/claude/claude-structured-session-reading-control.test.ts @@ -20,6 +20,7 @@ import { identityFor, PROVIDER_SESSION_ID } from './claude-structured-session-test-support' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' function controlledSink(): { sink: StructuredAgentSessionEventSink @@ -212,6 +213,7 @@ describe('Claude structured reading control', () => { const persisted = new Map() const target = persistedTarget(persisted) const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(), watermarks: { pauseQueuedOperations: 1, maxQueuedOperations: 4, diff --git a/src/main/claude/claude-subagent-resume-after-restart.test.ts b/src/main/claude/claude-subagent-resume-after-restart.test.ts index ef6c067db9e..c4b848eee44 100644 --- a/src/main/claude/claude-subagent-resume-after-restart.test.ts +++ b/src/main/claude/claude-subagent-resume-after-restart.test.ts @@ -16,6 +16,7 @@ import { createTrackedJournalOpener } from '../native-chat/agent-session-journal import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { claudeSubagentGroupBody, claudeSubagentGroupIdentity } from './claude-subagent-group-row' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' // Frame orders are real sessions', scrubbed. A resumed agent's frames still name its ORIGINAL // spawn call while the announcement names the message call that resumed it, and a new provider @@ -137,7 +138,7 @@ async function openJournal(): Promise { /** One provider process: a fresh sink and translator over the session's journal. */ function acquire(journal: AgentSessionJournal) { - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const translator = createClaudeJournalTranslator({ sink: deferred.sink, coalesceMs: 0 }) deferred.bind({ journal, fence: 1, publish: () => {} }) const settle = async (): Promise => { @@ -190,7 +191,7 @@ const rosterIds = (journal: AgentSessionJournal): Set => /** An older build re-rostered a resumed child in the later turn's row, so two rows list it. */ async function journalAnOlderBuildListedTwice(): Promise { const journal = await openJournal() - const older = createDeferredStructuredAgentSessionEventSink() + const older = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) older.bind({ journal, fence: 1, publish: () => {} }) const listed = (id: string, state: NativeChatSubagentEntry['state']) => ({ id, diff --git a/src/main/claude/claude-subagent-roster.test.ts b/src/main/claude/claude-subagent-roster.test.ts index 34ff506ee03..2bcffcfb49f 100644 --- a/src/main/claude/claude-subagent-roster.test.ts +++ b/src/main/claude/claude-subagent-roster.test.ts @@ -14,6 +14,7 @@ import { type StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { ClaudeSubagentRoster } from './claude-subagent-roster' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const TURN_1 = 'claude-session:turn-1' @@ -462,7 +463,7 @@ describe('ClaudeSubagentRoster — through the real sink queue', () => { }, appendTombstone: async () => ({ epoch: 'e', sequence: 0 }) } as unknown as AgentSessionJournal - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind({ journal, fence: 1, diff --git a/src/main/codex/codex-provider-retry-idle-sweep.test.ts b/src/main/codex/codex-provider-retry-idle-sweep.test.ts index b1e8b8b6510..39291b11b19 100644 --- a/src/main/codex/codex-provider-retry-idle-sweep.test.ts +++ b/src/main/codex/codex-provider-retry-idle-sweep.test.ts @@ -18,6 +18,7 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { createCodexJournalTranslator } from './codex-structured-journal-translation' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const SWEEP_MS = 5 const RETRY_GAP_MS = 10 * 60_000 @@ -75,6 +76,7 @@ beforeEach(async () => { setOption: async () => undefined } host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter, journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/codex/codex-structured-journal-goal-resume.test.ts b/src/main/codex/codex-structured-journal-goal-resume.test.ts index 6df0ba01575..9edeb004332 100644 --- a/src/main/codex/codex-structured-journal-goal-resume.test.ts +++ b/src/main/codex/codex-structured-journal-goal-resume.test.ts @@ -12,6 +12,7 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexJournalGoals } from './codex-structured-journal-goals' import { MAX_CODEX_GOAL_THREADS } from './codex-structured-journal-limits' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const THREAD = '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc' @@ -34,7 +35,7 @@ function goalFrame(goal: Record = {}): Record } function goalJournal( - options: Parameters[0] = {} + options: Partial[0]> = {} ) { let rowSequence = 0 let publishes = 0 @@ -43,7 +44,10 @@ function goalJournal( let visitedItems = 0 const rows = new Map() const writes: string[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink(options) + const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(), + ...options + }) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a fake journal exposing only the members the goal translator and deferred sink call. const journal = { get epoch() { diff --git a/src/main/codex/codex-structured-journal-goal-revision.test.ts b/src/main/codex/codex-structured-journal-goal-revision.test.ts index 4f827600577..aa455403ea7 100644 --- a/src/main/codex/codex-structured-journal-goal-revision.test.ts +++ b/src/main/codex/codex-structured-journal-goal-revision.test.ts @@ -14,6 +14,7 @@ import { createTrackedJournalOpener } from '../native-chat/agent-session-journal import { readAgentSessionHistory } from '../native-chat/agent-session-wire/agent-session-history-page' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexJournalGoals } from './codex-structured-journal-goals' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const THREAD = '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc' const IDENTITY: AgentSessionJournalIdentity = { @@ -56,7 +57,7 @@ function goalFrame(goal: Record = {}) { /** The host's own sink bound to a real journal. Its publish is what a subscriber * receives: the page after the cursor it had caught up to. */ function journalSink(journal: AgentSessionJournal) { - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const published: AgentSessionHistoryPage[] = [] let subscriberCursor: AgentJournalCursor | null = null deferred.bind({ diff --git a/src/main/codex/codex-structured-journal-subagent-readers.test.ts b/src/main/codex/codex-structured-journal-subagent-readers.test.ts index 21ae85af643..a99b815bff4 100644 --- a/src/main/codex/codex-structured-journal-subagent-readers.test.ts +++ b/src/main/codex/codex-structured-journal-subagent-readers.test.ts @@ -21,6 +21,7 @@ import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/ag import { createCodexJournalTranslator } from './codex-structured-journal-translation' import type { CodexThreadItem } from './codex-thread-item-identity' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const SESSION = 'session-codex-children' const PARENT = 'thread-parent' @@ -52,7 +53,7 @@ async function openJournal(root: string): Promise { async function session() { const root = await mkdtemp(join(tmpdir(), 'orca-codex-children-')) let journal = await openJournal(root) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const activities: (AgentSessionTurnActivity | null)[] = [] deferred.bind({ journal, diff --git a/src/main/codex/codex-structured-journal-translation-settlement.test.ts b/src/main/codex/codex-structured-journal-translation-settlement.test.ts index 344d99e03b4..6f00bdbe2b8 100644 --- a/src/main/codex/codex-structured-journal-translation-settlement.test.ts +++ b/src/main/codex/codex-structured-journal-translation-settlement.test.ts @@ -25,6 +25,7 @@ import { CODEX_USER_INPUT_METHOD } from './codex-structured-prompt-replies' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const SESSION_ID = 'session-1' const THREAD_ID = 'thread-abc' @@ -129,6 +130,7 @@ function deferredTarget( function hardWatermarkDeferred() { return createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(), watermarks: { pauseQueuedBytes: 1, maxQueuedBytes: 1, diff --git a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts index f9714bbe062..07115c4b4e7 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts @@ -26,6 +26,7 @@ import { import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' import type { CodexSession } from './codex-structured-session-state' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const SESSION_ID = 'session-1' const THREAD_ID = 'thread-abc' @@ -223,7 +224,7 @@ describe('codex turn lifecycle rows', () => { now: () => 9_000, stateDirectory: join(root, SESSION_ID) }) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const translator = createCodexJournalTranslator({ sink: deferred.sink, sessionId: SESSION_ID, diff --git a/src/main/codex/codex-structured-journal-translation.test.ts b/src/main/codex/codex-structured-journal-translation.test.ts index 676d9054ed7..523dd1894f8 100644 --- a/src/main/codex/codex-structured-journal-translation.test.ts +++ b/src/main/codex/codex-structured-journal-translation.test.ts @@ -18,6 +18,7 @@ import { CODEX_USER_INPUT_METHOD } from './codex-structured-prompt-replies' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const SESSION_ID = 'session-1' const THREAD_ID = 'thread-abc' @@ -127,6 +128,7 @@ function deferredTarget( function hardWatermarkDeferred() { return createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(), watermarks: { pauseQueuedBytes: 1, maxQueuedBytes: 1, @@ -713,6 +715,7 @@ describe('codex journal translation', () => { const publishes: string[] = [] const readingControl = { pauseReading: vi.fn(), resumeReading: vi.fn() } const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(), watermarks: { pauseQueuedBytes: 1, maxQueuedBytes: 1, diff --git a/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts b/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts index 7011cfa0302..da24f836d1a 100644 --- a/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts +++ b/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts @@ -13,6 +13,7 @@ import { import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const SESSION = 'session-codex-failed-turn' const THREAD = 'thread-abc' @@ -39,7 +40,7 @@ async function session() { stateDirectory: root, now: () => 1_000 }) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind({ journal, fence: 1, publish: () => {} }) cleanups.push(async () => { deferred.close() diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index 069fe4eb048..8ad4b75bd38 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -36,6 +36,7 @@ import { structuredQuestionTranscript } from '../../renderer/src/components/native-chat/structured-agent-question-projection' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -93,6 +94,7 @@ beforeEach(async () => { } store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter, journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts index 44a68b7696b..787b1aa6491 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { NO_LEGACY_JOURNAL_RECORDS, openJournalDatabase } from './journal-database' import { @@ -14,6 +14,7 @@ import { import { AgentSessionJournalError } from './journal-write-guards' import { journalDatabasePath } from './journal-host-database' import { replayJournal } from './journal-open' +import { recordingStructuredAgentSessionLogger } from '../agent-session-wire/structured-agent-session-logger-test-support' let root: string @@ -111,30 +112,39 @@ describe('classifyJournalOpenFailure', () => { describe('journalOpenReadRefusal', () => { it('names the reason, keeps the message the code and the storage text only as the cause', () => { - vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const log = recordingStructuredAgentSessionLogger() const storage = nodeSqliteError(26) - const refusal = journalOpenReadRefusal(storage) + const refusal = journalOpenReadRefusal(storage, log.logger, 'session-1') expect(refusal.message).toBe('agent_session_journal_unreadable') expect(refusal.refusal).toMatchObject({ code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } }) expect(refusal.cause).toBe(storage) - vi.restoreAllMocks() + expect(log.entries).toEqual([ + { + level: 'warn', + message: 'opening the conversation for a read failed', + fields: { scope: 'open-for-read', sessionId: 'session-1', error: storage } + } + ]) }) it('passes a refusal the open already raised through unchanged', () => { const raised = agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) - expect(journalOpenReadRefusal(raised)).toBe(raised) + expect( + journalOpenReadRefusal(raised, recordingStructuredAgentSessionLogger().logger, 's') + ).toBe(raised) }) }) describe('createJournalOpenReadRefusals', () => { it('logs a session once per failure until it opens, and each session on its own', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const refusals = createJournalOpenReadRefusals() + const log = recordingStructuredAgentSessionLogger() + const logged = () => log.entries.map((entry) => entry.fields.sessionId) + const refusals = createJournalOpenReadRefusals(log.logger) const denied = systemError('EACCES', -13) const corrupt = nodeSqliteError(26) @@ -143,17 +153,21 @@ describe('createJournalOpenReadRefusals', () => { expect(refusal.refusal).toMatchObject({ details: { reason: 'journalUnavailable' } }) expect(refusal.cause).toBe(denied) } - expect(warn).toHaveBeenCalledTimes(1) + expect(logged()).toEqual(['session-1']) refusals.refusal('session-2', denied) - expect(warn).toHaveBeenCalledTimes(2) + expect(logged()).toEqual(['session-1', 'session-2']) expect(refusals.refusal('session-1', corrupt).refusal).toMatchObject({ details: { reason: 'journalCorrupt' } }) - expect(warn).toHaveBeenCalledTimes(3) + expect(logged()).toEqual(['session-1', 'session-2', 'session-1']) refusals.forget('session-1') refusals.refusal('session-1', corrupt) - expect(warn).toHaveBeenCalledTimes(4) - vi.restoreAllMocks() + expect(log.scopes()).toEqual([ + 'open-for-read', + 'open-for-read', + 'open-for-read', + 'open-for-read' + ]) }) }) @@ -176,13 +190,12 @@ describe('a journal a newer Orca wrote', () => { }) it('refuses a read with the same reason', () => { - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - expect(journalOpenReadRefusal(readOnly()).refusal).toMatchObject({ + const { logger } = recordingStructuredAgentSessionLogger() + expect(journalOpenReadRefusal(readOnly(), logger, 'session-1').refusal).toMatchObject({ details: { reason: 'journalWrittenByNewerOrca' } }) - expect(createJournalOpenReadRefusals().refusal('session-1', readOnly()).refusal).toMatchObject({ - details: { reason: 'journalWrittenByNewerOrca' } - }) - vi.restoreAllMocks() + expect( + createJournalOpenReadRefusals(logger).refusal('session-1', readOnly()).refusal + ).toMatchObject({ details: { reason: 'journalWrittenByNewerOrca' } }) }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.ts index 5e014c279cb..d154e1b29e6 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.ts @@ -13,6 +13,7 @@ import { } from '../../../shared/agent-session-wire-refusals' import { isSqliteCorruption } from '../../sqlite/sqlite-read-failure' import { AgentSessionJournalError } from './journal-write-guards' +import type { StructuredAgentSessionLogger } from '../agent-session-wire/structured-agent-session-logger' type JournalRefusalReason = AgentSessionRefusalReason<'agent_session_journal_unreadable'> @@ -107,11 +108,15 @@ export function journalOpenRefusalError(error: unknown): AgentSessionRefusalErro * (a path, "file is not a database") goes to the log only; the reader gets the classified refusal, * whose message stays the bare code. */ -export function journalOpenReadRefusal(error: unknown): AgentSessionRefusalError { +export function journalOpenReadRefusal( + error: unknown, + logger: StructuredAgentSessionLogger, + sessionId: string +): AgentSessionRefusalError { if (isAgentSessionRefusalError(error)) { return error } - return unreadableRefusal(error, journalRefusalReason(error), true) + return unreadableRefusal(error, journalRefusalReason(error), { logger, sessionId }) } const MAX_LOGGED_SESSIONS = 256 @@ -120,7 +125,7 @@ const MAX_LOGGED_SESSIONS = 256 * The read door's refusals for one host. A reader reconnects on a timer while an open can clear, * so a session's failure is logged once until that session opens or the failure changes. */ -export function createJournalOpenReadRefusals() { +export function createJournalOpenReadRefusals(logger: StructuredAgentSessionLogger) { const logged = new Map() return { refusal: (sessionId: string, error: unknown): AgentSessionRefusalError => { @@ -134,7 +139,7 @@ export function createJournalOpenReadRefusals() { if (!repeat && (logged.has(sessionId) || logged.size < MAX_LOGGED_SESSIONS)) { logged.set(sessionId, failure) } - return unreadableRefusal(error, reason, !repeat) + return unreadableRefusal(error, reason, repeat ? null : { logger, sessionId }) }, /** The session opened or closed: its next failure is news. */ forget: (sessionId: string): void => { @@ -146,11 +151,13 @@ export function createJournalOpenReadRefusals() { function unreadableRefusal( error: unknown, reason: JournalRefusalReason, - log: boolean + log: { logger: StructuredAgentSessionLogger; sessionId: string } | null ): AgentSessionRefusalError { - if (log) { - console.warn('[agent-session] opening the conversation for a read failed:', error) - } + log?.logger.warn('opening the conversation for a read failed', { + scope: 'open-for-read', + sessionId: log.sessionId, + error + }) const code = 'agent_session_journal_unreadable' return new AgentSessionRefusalError(refuse(code, { reason }, code), { cause: error }) } diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts index c9737bf2007..9ec154d0e5d 100644 --- a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts @@ -28,6 +28,7 @@ import { import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths' import { importPerSessionJournal } from './journal-per-session-import' import { readJournalSessionEpoch, type JournalStoredRow } from './journal-row-table' +import { createStructuredAgentSessionLogger } from '../agent-session-wire/structured-agent-session-logger' vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() @@ -221,6 +222,7 @@ describe('importing a per-chat journal', () => { const journal = await openChat() const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([[IDENTITY.sessionId, { journal, child: null }]]), now: () => clock, enqueue: (operation) => operation() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index a80c147607d..b3f91aea700 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -45,6 +45,7 @@ import { import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -76,6 +77,7 @@ const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spa async function startHost(): Promise { host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index a0d2f0fdb7d..c98854292d5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -20,6 +20,7 @@ import { openTestAttachConversation } from './structured-agent-session-attach-te import { performAttach } from './structured-agent-session-attach-flow' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const NOW = 1_800_000_000_000 const SESSION = 'legacy-session' @@ -151,6 +152,7 @@ describe('structured session acquisition options', () => { let firstJournal: AgentSessionJournal | undefined const first = await performAttach({ + logger: createStructuredAgentSessionLogger(), store: initialStore, adapter: withHistory('created'), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), @@ -188,6 +190,7 @@ describe('structured session acquisition options', () => { const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const second = await performAttach({ + logger: createStructuredAgentSessionLogger(), store, adapter: withHistory('resumed'), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), @@ -219,6 +222,7 @@ describe('structured session acquisition options', () => { const recordPhase = vi.fn() const created = await performAttach({ + logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), @@ -248,6 +252,7 @@ describe('structured session acquisition options', () => { const sessionAdapter = adapter({ origin: 'created' }) const attempt = async (options: Readonly>, spawnToken: string) => performAttach({ + logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), @@ -278,6 +283,7 @@ describe('structured session acquisition options', () => { const store = await openTestAgentSessionRecordStore(root) const created = await performAttach({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter({ origin: 'created' }), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), @@ -308,6 +314,7 @@ describe('structured session acquisition options', () => { }) const releasedFence = resumedStore.getRecord(SESSION)?.lease.runtimeFence ?? 0 const resumed = await performAttach({ + logger: createStructuredAgentSessionLogger(), store: resumedStore, adapter: adapter({ origin: 'resumed', @@ -350,6 +357,7 @@ describe('structured session acquisition options', () => { }) const created = await performAttach({ + logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), @@ -386,6 +394,7 @@ describe('structured session acquisition options', () => { await expect( performAttach({ + logger: createStructuredAgentSessionLogger(), store, adapter: failingAdapter, openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), @@ -479,6 +488,7 @@ describe('structured session acquisition options', () => { fence: number | null ) => performAttach({ + logger: createStructuredAgentSessionLogger(), store: target, adapter: failingAdapter, openConversation: openTestAttachConversation( @@ -580,6 +590,7 @@ describe('the tab a create reserves', () => { function attachWith(store: AgentSessionRecordStore, surfaceTabId?: string) { return performAttach({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter({ origin: 'created' }), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 6bb43fb9d31..76a276abd23 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -19,6 +19,7 @@ import { AgentSessionJournal } from '../agent-session-journal/journal-store' import * as legacyImport from '../agent-session-journal/journal-legacy-import' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const NOW = 1_800_000_000_000 const SESSION = 'codex_adopting_session' @@ -122,6 +123,7 @@ async function attach( ) { store ??= await openTestAgentSessionRecordStore(root!) return performAttach({ + logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), @@ -233,6 +235,7 @@ describe('adopting a provider conversation on create', () => { await writeCodexRollout(transcriptPath, 'valid source') store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index a4c93fa00f8..6fbcca0dce2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -79,7 +79,11 @@ export function ensureStructuredAgentSessionAgentForOperation( ): Promise { return ensureStructuredAgentSessionAgent(context, sessionId).catch((error: unknown) => { // The error is Orca's own and goes to the log; the refusal says only that the start failed. - console.warn('[agent-session] starting the agent for an operation failed:', error) + context.deps.logger.warn('starting the agent for an operation failed', { + scope: 'operation-agent-start', + sessionId, + error + }) return { ok: false, refusal: refuseUnclassified( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index bda7cc9330f..e2adb2953cb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -128,6 +129,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts index 063cf84ebd7..140a9920115 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts @@ -18,6 +18,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { HOST_TEST_LOCATION, HOST_TEST_NOW, @@ -87,6 +88,7 @@ async function openHost(catalog = catalogFor(workspace)): Promise { adapter: adapter(catalog), journalDatabase: openTestJournalHostDatabase(directory), claimKeyId: 'key', + logger: recordingStructuredAgentSessionLogger().logger, now: () => clock, probeOwner: async () => ({ outcome: 'pid-absent' }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index c469035c468..d1b6cd28087 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -44,10 +44,12 @@ import { } from '../../observability/agent-session-instrumentation' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type AttachFlowInput = { store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter + logger: StructuredAgentSessionLogger authority: AgentSessionAttachAuthority callerKey: string params: AgentSessionAttachParams @@ -206,7 +208,11 @@ export async function performAttach( const thrown = failed ? error : preSpawnFailureInWords(error, wording) if (failed || thrown !== error) { // The answer carries only its sentence, so what failed is kept here. - console.warn('[agent-session] provider start failed:', error) + input.logger.warn('starting the provider failed', { + scope: 'provider-start', + sessionId, + error + }) } return ( failed ?? { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 9cfdf6a9b87..d8f89065de4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -135,6 +135,7 @@ async function runAttach( const attached = await performAttach({ store: context.deps.store, adapter: context.deps.adapter, + logger: context.deps.logger, eventSink: attemptSink.sink, // The superseded child's writes settle into its own journal before a new child starts. onAcquiring: async () => { @@ -201,7 +202,7 @@ async function runAttach( } } await recoverStructuredRewind( - context.deps.store, + context.deps, sessionId, attached.journal, fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts index aa0ae9e29e8..7a9f758858a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts @@ -3,6 +3,7 @@ import type { JournalHostDatabase } from '../agent-session-journal/journal-host- import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { openStructuredAgentSessionConversationJournal } from './structured-agent-session-conversation-open' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' /** For a test that attaches without a host: the conversation's journal, through the one open a * host would take, so the attach under test adopts it the way it adopts the host's. */ @@ -12,8 +13,12 @@ export function openTestAttachConversation( ): (record: AgentSessionRecord) => Promise { return async (record) => ( - await openStructuredAgentSessionConversationJournal({ journalDatabase, adapter }, record, { - acquisition: true - }) + await openStructuredAgentSessionConversationJournal( + { journalDatabase, adapter, logger: recordingStructuredAgentSessionLogger().logger }, + record, + { + acquisition: true + } + ) ).session.journal } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts index 11505b6c204..92c52f88a68 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts @@ -10,6 +10,7 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import type { AgentSessionSubscribers } from './structured-agent-session-subscribers' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const child: AgentChildWorkView = { id: 'child-1', @@ -31,7 +32,7 @@ function channelOver( ) { const sessions = new StructuredAgentSessionConversations({ deliver: () => {}, - onDeliveryError: () => {}, + logger: recordingStructuredAgentSessionLogger().logger, now: () => 1 }) const sent = vi.fn() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index af618040fa3..70707019295 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -108,7 +108,12 @@ export function mutateWithChatStop( { sessionId, adapter: context.deps.adapter }, windDown, () => context.stopAgent(sessionId), - (error) => context.deps.onEventSinkError?.({ sessionId, error }) + (error) => + context.deps.logger.warn("ending a stopped chat's provider session failed", { + scope: 'chat-stop', + sessionId, + error + }) ) } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts index 041b6580ea2..bbdc2b50b2f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts @@ -28,6 +28,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -70,6 +71,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, // The production router is what declares create support; the bare adapter only knows locations. adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts index 1ba4f13e4ea..747319c3c66 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts @@ -27,6 +27,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -66,6 +67,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts index 6398d96ab0a..1bb3d36a5c4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts @@ -16,6 +16,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-claude' } const CLAUDE_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' @@ -83,6 +84,7 @@ beforeEach(async () => { optionWritable = Promise.resolve() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index 879f83b8a6c..2531450fd22 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -29,6 +29,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } // As Claude Code 2.1.280 advertises them on a turn's system/init frame. @@ -82,6 +83,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index 778bd3cc2fa..f09fe672b5e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -17,6 +17,7 @@ import type { AgentSessionAttachParams } from './structured-agent-session-attach import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const NOW = 1_788_727_031_330 const roots: string[] = [] @@ -121,7 +122,8 @@ describe('Claude root-exit stop', () => { adapter, // The one database the store and the journal share, as the runtime installs them. journalDatabase: openTestJournalHostDatabase(stateDirectory), - claimKeyId: 'key-1' + claimKeyId: 'key-1', + logger: createStructuredAgentSessionLogger() } const runtimeState = new StructuredAgentSessionHostRuntimeState(deps) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index ad314376b1a..845647f3c0a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -32,6 +32,7 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -52,7 +53,7 @@ let store: AgentSessionRecordStore let queued: string[] let claude: ReturnType let events: ClaudeStructuredSessionEvent[] -let sinkErrors: unknown[] +let logs: ReturnType // The host's status row and child records, as the app's hook server holds them. let server: AgentHookServer let statusSubject: AgentStatusStructuredSessionSubject | undefined @@ -62,7 +63,7 @@ beforeEach(async () => { resetHostTestOperationIds() queued = [] events = [] - sinkErrors = [] + logs = recordingStructuredAgentSessionLogger() server = new AgentHookServer() statusSubject = undefined claude = fakeClaude({ @@ -107,7 +108,7 @@ beforeEach(async () => { journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', - onEventSinkError: ({ error }) => sinkErrors.push(error), + logger: logs.logger, statusSink: { publish: (summary, subject) => { statusSubject = subject @@ -601,10 +602,15 @@ it('still answers the Stop, with its row, when the child cannot be proven gone; await laneDrained() expect(await statusTexts()).toEqual(['Cancellation requested.']) - expect(sinkErrors).toEqual([ + expect(logs.entries).toEqual([ expect.objectContaining({ - name: 'StructuredAgentSessionEvictionError', - step: 'stop-provider-child' + fields: expect.objectContaining({ + scope: 'chat-stop', + error: expect.objectContaining({ + name: 'StructuredAgentSessionEvictionError', + step: 'stop-provider-child' + }) + }) }) ]) connection.close = close @@ -660,7 +666,7 @@ it('keeps a second Stop pressed while the first ends the child quiet', async () expect(connection.closed).toBe(true) expect(connection.calls.filter((call) => call.subtype === 'interrupt')).toHaveLength(1) expect(await statusTexts()).toEqual(['Cancellation requested.']) - expect(sinkErrors).toEqual([]) + expect(logs.entries).toEqual([]) }) const BRANCH_QUESTION = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index 83d80437de3..4b51f9cfe77 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -31,6 +31,7 @@ import { childEndCauseOfEndedEvent, turnVerdictForChildEnd } from './structured-agent-session-stale-turn-verdict' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CUT_TURN = { provider: 'codex' as const, threadId: THREAD, turnId: 'cut-turn', ordinal: 1 } @@ -51,6 +52,7 @@ beforeEach(() => { exitObservedFirst = false closeCalls = 0 host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store: state.store, adapter: { ...adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index 8833ac4ee31..9103393c26c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -24,6 +24,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -51,6 +52,7 @@ beforeEach(async () => { } const store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapterFor(codex), { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index 2dd2b5229aa..499c5f3ff48 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -317,8 +317,14 @@ describe('the wind-down retry with a message queued (P2-31)', () => { stopAgent, stopStartingAgent: stopAgent, closeConversation: vi.fn(async () => false), - onError: (_id, error) => { - throw error + // A failed step fails the test. + logger: { + warn: (_message, fields) => { + throw fields.error + }, + error: (_message, fields) => { + throw fields.error + } } }) await sweep.tick() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 1980616f176..491021dc1c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -24,6 +24,7 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-sessi import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { adapterSupportsRecord } from './structured-agent-session-provider-support' +import { deferredStructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionConversationLifetime = ReturnType< typeof createStructuredAgentSessionConversationLifetime @@ -45,7 +46,9 @@ export function createStructuredAgentSessionConversationLifetime(host: { let disposed = false const { sessions, serialize } = host const deps = () => host.context().deps - const readRefusals = createJournalOpenReadRefusals() + const readRefusals = createJournalOpenReadRefusals( + deferredStructuredAgentSessionLogger(() => deps().logger) + ) // The owed copy fails as an open does: the reader gets the classified refusal, never its text. const whenImported = (sessionId: string, session: StructuredAgentSessionHostSession) => session.journal.whenImported().catch((error: unknown) => { @@ -88,7 +91,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { cause: 'host-stop' }), closeConversation, - onError: (sessionId, error) => deps().onEventSinkError?.({ sessionId, error }), + logger: deps().logger, ...deps().idleSweep }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index ec938f17f13..73285ee1de4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -38,7 +38,7 @@ export type StructuredAgentSessionConversationOpenDeps = { store: Pick adapter: Pick journalDatabase: JournalHostDatabase - onEventSinkError?: StructuredAgentSessionHostDeps['onEventSinkError'] + logger: StructuredAgentSessionHostDeps['logger'] } /** An acquisition's own open: its reserve cleared the record's death evidence, so it settles @@ -104,7 +104,11 @@ export async function openStructuredAgentSessionConversationJournal( // one is only doubt, which provider history decides under a won lease. await opened.journal.markPendingSubmissionsUnknown(fence) } catch (error) { - deps.onEventSinkError?.({ sessionId, error }) + deps.logger.warn('marking pending sends unknown on open failed', { + scope: 'open-pending-unknown', + sessionId, + error + }) } // No child in this process writes to a journal nobody had open, so whatever it shows running // belongs to a generation that is gone, whatever the lease still claims. Settled before any @@ -135,7 +139,7 @@ export async function resettleOpenStructuredAgentSessionConversation( } async function settleGoneGeneration( - deps: Pick, + deps: Pick, record: AgentSessionRecord, journal: AgentSessionJournal ): Promise { @@ -150,7 +154,11 @@ async function settleGoneGeneration( }) } catch (error) { // Best effort: the next open or acquire re-derives it. - deps.onEventSinkError?.({ sessionId: record.sessionId, error }) + deps.logger.warn("settling a gone agent's work on open failed", { + scope: 'open-dead-generation', + sessionId: record.sessionId, + error + }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index 55f58d06a59..0f7c471ed0a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -55,6 +56,7 @@ beforeEach(async () => { stopEndsSession = false store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire: async ({ fence, spawnToken, events: sink }) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts index 3557594b751..ec4c76e26ba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts @@ -9,6 +9,7 @@ import { createTrackedJournalOpener } from '../agent-session-journal/journal-hos import { StructuredAgentSessionConversations } from './structured-agent-session-conversations' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { hostTestAttachParams } from './structured-agent-session-host-test-data' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -55,7 +56,7 @@ describe('a conversation delivers what its journal commits', () => { const deliver = vi.fn() const conversations = new StructuredAgentSessionConversations({ deliver, - onDeliveryError: vi.fn(), + logger: recordingStructuredAgentSessionLogger().logger, now: () => 0 }) const journal = await openJournal('a') @@ -74,7 +75,7 @@ describe('a conversation delivers what its journal commits', () => { const sessions: Map = new StructuredAgentSessionConversations({ deliver, - onDeliveryError: vi.fn(), + logger: recordingStructuredAgentSessionLogger().logger, now: () => 0 }) const journal = await openJournal('a') @@ -89,7 +90,7 @@ describe('a conversation delivers what its journal commits', () => { const deliver = vi.fn() const conversations = new StructuredAgentSessionConversations({ deliver, - onDeliveryError: vi.fn(), + logger: recordingStructuredAgentSessionLogger().logger, now: () => 0 }) const journal = await openJournal('a') @@ -104,7 +105,7 @@ describe('a conversation delivers what its journal commits', () => { const deliver = vi.fn() const conversations = new StructuredAgentSessionConversations({ deliver, - onDeliveryError: vi.fn(), + logger: recordingStructuredAgentSessionLogger().logger, now: () => 0 }) const replaced = await openJournal('a') @@ -123,7 +124,7 @@ describe('a conversation delivers what its journal commits', () => { const deliver = vi.fn() const conversations = new StructuredAgentSessionConversations({ deliver, - onDeliveryError: vi.fn(), + logger: recordingStructuredAgentSessionLogger().logger, now: () => 0 }) const journal = await openJournal('a') @@ -137,12 +138,12 @@ describe('a conversation delivers what its journal commits', () => { it('reports a reader failure without failing the durable write', async () => { const failure = new Error('reader failed') - const onDeliveryError = vi.fn() + const log = recordingStructuredAgentSessionLogger() const conversations = new StructuredAgentSessionConversations({ deliver: () => { throw failure }, - onDeliveryError, + logger: log.logger, now: () => 0 }) const journal = await openJournal('a') @@ -152,7 +153,9 @@ describe('a conversation delivers what its journal commits', () => { itemId: expect.any(String) }) - expect(onDeliveryError).toHaveBeenCalledExactlyOnceWith('session-1', failure) + expect(log.entries.map((entry) => entry.fields)).toEqual([ + { scope: 'journal-delivery', sessionId: 'session-1', error: failure } + ]) expect(journal.snapshot().items.map((item) => item.body)).toContainEqual({ kind: 'status', text: 'durable' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts index 70fe538ce20..b634b74c212 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts @@ -1,5 +1,6 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' /** * The host's open conversations. A journal handle becomes a conversation's when it is set here, @@ -22,7 +23,7 @@ export class StructuredAgentSessionConversations extends Map< constructor( private readonly delivery: { deliver: (sessionId: string, journal: AgentSessionJournal) => void - onDeliveryError: (sessionId: string, error: unknown) => void + logger: StructuredAgentSessionLogger /** A conversation became held: state that waited on it (queued drafts) re-derives. */ onOpened?: (sessionId: string) => void now: () => number @@ -47,7 +48,11 @@ export class StructuredAgentSessionConversations extends Map< try { this.delivery.deliver(sessionId, journal) } catch (error) { - this.delivery.onDeliveryError(sessionId, error) + this.delivery.logger.warn('delivering a journal commit failed', { + scope: 'journal-delivery', + sessionId, + error + }) } }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts index 22295ea6e86..059567386a6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts @@ -25,6 +25,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const CHILD_PID = 4321 @@ -92,6 +93,7 @@ function host( overrides: Partial = {} ): StructuredAgentSessionHost { return new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter, journalDatabase: openTestJournalHostDatabase(generationRoot(generation)), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index 9d396b58ffa..7e237615089 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -45,6 +45,8 @@ import { HOST_TEST_LOCATION as LOCATION, HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const PROVIDER_SESSION = 'provider-session-alpha-1' /** The tool call's row: the last thing the provider wrote before the crash. */ @@ -155,6 +157,7 @@ async function seedClaudeToolTurn(): Promise { function openHost(overrides: Partial): void { host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire: vi.fn(), @@ -440,11 +443,11 @@ describe('a turn a read reached before the reconcile proved its owner dead', () it('stays unverifiable when the revision cannot be written, and a later open revises it', async () => { let now = RELAUNCHED_AT - const onEventSinkError = vi.fn() + const log = recordingStructuredAgentSessionLogger() openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }), now: () => now, - onEventSinkError + logger: log.logger }) await host.history({ sessionId: SESSION, direction: 'tail' }) const { journal } = host.collaboratorsForTests().sessions.get(SESSION)! @@ -453,7 +456,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () await host.reconcileRestartLeases() await drainSession() - expect(onEventSinkError).toHaveBeenCalledOnce() + expect(log.scopes()).toEqual(['open-dead-generation']) expect(await settledTurn()).toEqual(UNVERIFIABLE_TURN) // The proof is durable on the record, so the next open converges. now += STRUCTURED_AGENT_SESSION_IDLE_MS + 1 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 6677a50b447..b39790e6b2a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -100,7 +100,7 @@ describe('dead structured-session generation settlement', () => { verdict: { state: 'unverifiable' }, showUnexpectedExitOutcome: false }) - ).resolves.toBe(true) + ).resolves.toEqual({ ok: true }) const snapshot = journal.snapshot() expect(snapshot.submissions).toEqual([ @@ -135,9 +135,9 @@ describe('dead structured-session generation settlement', () => { showUnexpectedExitOutcome: true } - await expect(settleStructuredAgentSessionDeadGeneration(input)).resolves.toBe(true) + await expect(settleStructuredAgentSessionDeadGeneration(input)).resolves.toEqual({ ok: true }) const settledCursor = journal.cursor() - await expect(settleStructuredAgentSessionDeadGeneration(input)).resolves.toBe(true) + await expect(settleStructuredAgentSessionDeadGeneration(input)).resolves.toEqual({ ok: true }) expect(journal.cursor()).toEqual(settledCursor) expect( @@ -166,7 +166,7 @@ describe('dead structured-session generation settlement', () => { detail: providerDiagnostic('stack frame '.repeat(4_000), 'log') }) }) - ).resolves.toBe(true) + ).resolves.toEqual({ ok: true }) const statuses = journal .snapshot() @@ -258,7 +258,7 @@ describe('dead structured-session generation settlement', () => { verdict: { state: 'interrupted', completedAt: 1_000 }, showUnexpectedExitOutcome: false }) - ).resolves.toBe(true) + ).resolves.toEqual({ ok: true }) }) it('settles a live unknown submission even when no unfinished item remains', async () => { @@ -285,7 +285,7 @@ describe('dead structured-session generation settlement', () => { verdict: { state: 'interrupted', completedAt: 1_000 }, showUnexpectedExitOutcome: false }) - ).resolves.toBe(true) + ).resolves.toEqual({ ok: true }) expect(journal.submissions()).toEqual([ expect.objectContaining({ @@ -463,7 +463,7 @@ describe('whether a dead generation interrupted anything', () => { 1_000 ) }) - ).resolves.toBe(true) + ).resolves.toEqual({ ok: true }) const snapshot = journal.snapshot() expect(snapshot.items.some((item) => item.body.kind === 'status')).toBe(false) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 72caac40844..8ec0768cc52 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -102,6 +102,11 @@ export function unfinishedStructuredAgentSessionWorkWasInterrupted( return outcomeItems.some((item) => !isCleanlySettled(currentItems.get(item.itemId))) } +/** Whether the settlement was written, and what stopped it when it was not. */ +export type StructuredAgentSessionDeadGenerationSettlement = + | { ok: true } + | { ok: false; error: unknown } + export async function settleStructuredAgentSessionDeadGeneration(input: { journal: DeadGenerationJournal sessionId: string @@ -117,13 +122,12 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { /** The provider never finished starting: the start that failed, keyed by the child's * generation. Its row is the one the delivery loop writes for the same start. */ exitedDuringStartup?: { generation: string | null } - onError?: (sessionId: string, error: unknown) => void -}): Promise { +}): Promise { try { const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal) const showUnexpectedExitOutcome = input.showUnexpectedExitOutcome ?? hasUnfinishedWork if (!showUnexpectedExitOutcome && !hasUnfinishedWork) { - return true + return { ok: true } } // A queued message is the delivery loop's to settle: it was never handed to this child. A // child that never proved its start accepted nothing either — input is written only after it @@ -189,10 +193,10 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { mutations: chunk.mutations }) } - return true + return { ok: true } } catch (error) { - input.onError?.(input.sessionId, error) - return false + // Returned rather than logged: each caller logs it under its own scope. + return { ok: false, error } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index ad149dbf947..3727040e3c0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -40,6 +40,7 @@ import { import { failedProviderChildStart } from './structured-agent-session-provider-child' import { handOverSubmission } from './structured-agent-session-turns' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionDeliveryLoopDeps = { sessions: ReadonlyMap @@ -62,7 +63,7 @@ export type StructuredAgentSessionDeliveryLoopDeps = { ) => Promise /** Who the chat's failure sentences name. */ failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext - onError: (sessionId: string, error: unknown) => void + logger: StructuredAgentSessionLogger record: (sessionId: string) => AgentSessionRecord | null readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined flushStreamedEvents: (sessionId: string) => Promise @@ -139,14 +140,22 @@ export class StructuredAgentSessionDeliveryLoop { } } catch (error) { // The error is Orca's own and goes to the log; the chat says only that Orca failed. - this.deps.onError(sessionId, error) + this.deps.logger.warn('delivering a queued message failed', { + scope: 'delivery-loop', + sessionId, + error + }) const cause = { hostFault: true } as const await this.deps .serialize(sessionId, () => this.fail(sessionId, { startKey: null, cause })) .catch((failure: unknown) => { // Rows left queued are rejected by the next open, or by the next loop an accept wakes. this.running.delete(sessionId) - this.deps.onError(sessionId, failure) + this.deps.logger.warn('recording a failed delivery failed', { + scope: 'delivery-loop-fail', + sessionId, + error: failure + }) }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 56a0acf853d..2b9340859f4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -22,10 +22,13 @@ export class StructuredAgentSessionEventRecovery { publishStatus?: (sessionId: string) => void serialize: (sessionId: string, task: () => Promise) => Promise now: () => number - onBarrierError: (sessionId: string, error: unknown) => void } ) {} + private get exitContext() { + return { ...this.context, logger: this.context.deps.logger } + } + recoverAfterSinkFailure(sessionId: string, error: unknown): void { if (this.sinkFailures.has(sessionId)) { return @@ -56,7 +59,16 @@ export class StructuredAgentSessionEventRecovery { } as const }) .then((event) => (event ? this.handle(event) : undefined)) - .catch((recoveryError) => this.context.onBarrierError(sessionId, recoveryError)) + .catch((error: unknown) => + this.context.deps.logger.warn( + 'stopping a provider after its journal failed did not finish', + { + scope: 'sink-failure-recovery', + sessionId, + error + } + ) + ) .finally(() => this.sinkFailures.delete(sessionId)) } @@ -66,6 +78,6 @@ export class StructuredAgentSessionEventRecovery { if (event.type === 'started') { return settleStructuredAgentSessionProviderStarted(this.context, event) } - await settleUnexpectedStructuredAgentSessionExit(this.context, event) + await settleUnexpectedStructuredAgentSessionExit(this.exitContext, event) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index 2d0766a4bce..508893d67f4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -45,7 +45,8 @@ export class StructuredAgentSessionSinkQueue { constructor( private readonly deps: { watermarks: StructuredAgentSessionSinkWatermarks - onError?: (error: unknown) => void + /** The queue just failed for good; it accepts and runs nothing more. */ + onFailed?: (error: unknown) => void readingControl?: StructuredAgentSessionReadingControl onBackpressureChange?: ( backpressured: boolean, @@ -212,7 +213,7 @@ export class StructuredAgentSessionSinkQueue { private fail = (error: unknown): void => { if (this.failure === null) { this.failure = { error } - this.deps.onError?.(error) + this.deps.onFailed?.(error) } this.queue.length = 0 this.queuedBytes = 0 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index 46dc8fbf7e4..2993ce35a4e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -15,6 +15,8 @@ import { type StructuredAgentSessionEventTarget } from './structured-agent-session-event-sink' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { testEventSinkLogging } from './structured-agent-session-logger-test-support' const BODY: AgentJournalItemBody = { kind: 'message', @@ -91,7 +93,7 @@ function target( describe('deferred structured agent-session event sink', () => { it('buffers writes made before the journal exists and drains them in arrival order', async () => { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) @@ -110,7 +112,7 @@ describe('deferred structured agent-session event sink', () => { it('writes at the fence bound at submission time, so a rebind cannot backdate a write', async () => { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind(target(1, log)) deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) @@ -127,7 +129,7 @@ describe('deferred structured agent-session event sink', () => { it('buffers replacement-acquisition events while unbound', async () => { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind(target(1, log)) deferred.unbind() @@ -141,7 +143,7 @@ describe('deferred structured agent-session event sink', () => { it('resolves a lifecycle transition after journal bind and skips an existing state', async () => { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) expect( deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => identity(1), { @@ -166,7 +168,7 @@ describe('deferred structured agent-session event sink', () => { it('drops buffered and later writes once closed, and refuses to rebind', async () => { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) deferred.close() @@ -182,7 +184,8 @@ describe('deferred structured agent-session event sink', () => { const errors: unknown[] = [] const readingControl = { pauseReading: vi.fn(), resumeReading: vi.fn() } const deferred = createDeferredStructuredAgentSessionEventSink({ - onError: (error) => errors.push(error), + ...testEventSinkLogging(), + onFailed: (error) => errors.push(error), readingControl }) deferred.bind(target(4, log, 0)) @@ -206,7 +209,11 @@ describe('deferred structured agent-session event sink', () => { }) it('replaces a failed cached sink before recovery drain', async () => { - const runtime = new StructuredAgentSessionHostRuntimeState({ store: {} } as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cached-sink path reads only the logger, on the failed drain. + const runtime = new StructuredAgentSessionHostRuntimeState({ + store: {}, + logger: createStructuredAgentSessionLogger() + } as never) const failed = runtime.eventSinkFor('session-1') failed.bind(target(1, [], 0)) failed.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) @@ -226,6 +233,7 @@ describe('deferred structured agent-session event sink', () => { const changes: boolean[] = [] const readingControl = { pauseReading: vi.fn(), resumeReading: vi.fn() } const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(), watermarks: { maxQueuedBytes: 1_000_000, lowQueuedBytes: 0, @@ -262,6 +270,7 @@ describe('deferred structured agent-session event sink', () => { it('admits a resolved append and publication as one bounded operation', async () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(), watermarks: { pauseQueuedOperations: 1, maxQueuedOperations: 2, @@ -299,6 +308,7 @@ describe('deferred structured agent-session event sink', () => { const changes: boolean[] = [] const readingControl = { pauseReading: vi.fn(), resumeReading: vi.fn() } const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(), watermarks: { pauseQueuedBytes: 1, maxQueuedBytes: 1_000_000, @@ -329,7 +339,8 @@ describe('deferred structured agent-session event sink', () => { const log: Recorded[] = [] const errors: unknown[] = [] const deferred = createDeferredStructuredAgentSessionEventSink({ - onError: (error) => errors.push(error), + ...testEventSinkLogging(), + onFailed: (error) => errors.push(error), watermarks: { pauseQueuedBytes: 1, maxQueuedBytes: 1, @@ -364,6 +375,7 @@ describe('deferred structured agent-session event sink', () => { const firstControl = { pauseReading: vi.fn(), resumeReading: vi.fn() } const secondControl = { pauseReading: vi.fn(), resumeReading: vi.fn() } const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(), watermarks: { pauseQueuedBytes: 1, maxQueuedBytes: 1_000_000, @@ -395,7 +407,7 @@ describe('deferred structured agent-session event sink', () => { it('replaces a queued same-item checkpoint before it runs', async () => { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const options = { coalescingKey: 'checkpoint:item-1', turnScope: AGENT_JOURNAL_THREAD_SCOPE } deferred.sink.appendItem(identity(0), BODY, options) @@ -411,7 +423,7 @@ describe('deferred structured agent-session event sink', () => { // The journal applies a settlement id once and skips any later batch with it, // so the queue must not let a later batch replace one it has not run yet. const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const batch = (ordinal: number) => [ { kind: 'item' as const, @@ -440,7 +452,7 @@ describe('deferred structured agent-session event sink', () => { it('keeps a replacement checkpoint after distinct intervening operations', async () => { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const options = { coalescingKey: 'checkpoint:item-1', turnScope: AGENT_JOURNAL_THREAD_SCOPE } deferred.sink.appendItem(identity(0), BODY, options) @@ -457,7 +469,7 @@ describe('deferred structured agent-session event sink', () => { it('coalesces provider activity as a publication without a journal write', async () => { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.sink.setActivity?.({ turnId: 'turn-1', text: 'Thinking' }) deferred.sink.setActivity?.({ turnId: 'turn-1', text: 'Checking the result' }) @@ -486,7 +498,7 @@ describe('producer linkage reaches the journal through every append path', () => it('forwards the whole bundle on the plain and try append paths', async () => { for (const append of ['appendItem', 'tryAppendItem'] as const) { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind(target(5, log)) deferred.sink[append]?.(identity(1), BODY, { ...LINKAGE, @@ -510,7 +522,7 @@ describe('producer linkage reaches the journal through every append path', () => 'tryAppendLifecycleTransition' ] as const) { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind(target(5, log)) deferred.sink[append]?.(identity(1), BODY, () => identity(1), { ...LINKAGE, @@ -530,7 +542,7 @@ describe('producer linkage reaches the journal through every append path', () => // would stamp whoever opened the batch onto all of them. Both callers are // single-producer today; a mixed batch would have to stamp per mutation. const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind(target(5, log)) deferred.sink.appendLifecycleBatch?.( 'settle-1', @@ -547,7 +559,7 @@ describe('producer linkage reaches the journal through every append path', () => it("writes no linkage keys at all for the session's own agent", async () => { const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind(target(5, log)) deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await deferred.drained() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 121fb865db4..18003e72369 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -12,6 +12,7 @@ import { estimateStructuredAgentSessionItemBytes } from './structured-agent-sess import { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' import { structuredAgentSessionJournalAppendOptions } from './structured-agent-session-journal-append-options' import { createStructuredAgentSessionResolvedAppend } from './structured-agent-session-resolved-append' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionSinkAdmission = | { accepted: true } @@ -193,18 +194,28 @@ const DEFAULT_WATERMARKS: StructuredAgentSessionSinkWatermarks = { maxLifecycleQueuedOperations: 1_024 } -export function createDeferredStructuredAgentSessionEventSink( - deps: { - onError?: (error: unknown) => void - watermarks?: Partial - readingControl?: StructuredAgentSessionReadingControl - onBackpressureChange?: (backpressured: boolean, state: StructuredAgentSessionSinkState) => void - } = {} -): DeferredStructuredAgentSessionEventSink { +export function createDeferredStructuredAgentSessionEventSink(deps: { + /** The session this sink writes for, named in every failure it logs. */ + sessionId: string + logger: StructuredAgentSessionLogger + /** The sink failed for good; the owner decides what that costs the provider. */ + onFailed?: (error: unknown) => void + watermarks?: Partial + readingControl?: StructuredAgentSessionReadingControl + onBackpressureChange?: (backpressured: boolean, state: StructuredAgentSessionSinkState) => void +}): DeferredStructuredAgentSessionEventSink { const watermarks = { ...DEFAULT_WATERMARKS, ...deps.watermarks } + const failed = (error: unknown): void => { + deps.logger.error('writing provider events to the chat journal failed', { + scope: 'journal-event-sink', + sessionId: deps.sessionId, + error + }) + deps.onFailed?.(error) + } const queue = new StructuredAgentSessionSinkQueue({ watermarks, - ...(deps.onError ? { onError: deps.onError } : {}), + onFailed: failed, ...(deps.readingControl ? { readingControl: deps.readingControl } : {}), ...(deps.onBackpressureChange ? { onBackpressureChange: deps.onBackpressureChange } : {}) }) @@ -280,7 +291,7 @@ export function createDeferredStructuredAgentSessionEventSink( appendLifecycleBatch: (settlementId, mutations, options = {}) => { const admission = appendLifecycleBatch(settlementId, mutations, options) if (!admission.accepted) { - deps.onError?.( + failed( new Error( `lifecycle journal batch ${settlementId} rejected by sink ${admission.reason}` ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts index 38349f95115..236dc7bc54b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -10,12 +10,15 @@ import { AgentSessionPreSpawnError } from './structured-agent-session-adapter' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' function context(): StructuredAgentSessionEvictionContext & { order: string[] } { const order: string[] = [] return { order, sessionId: 'session-1', + logger: createStructuredAgentSessionLogger(), eventSink: { unbind: vi.fn(() => order.push('unbind')), drained: vi.fn(async () => { @@ -44,9 +47,11 @@ function context(): StructuredAgentSessionEvictionContext & { order: string[] } } function runtimeState(): StructuredAgentSessionHostRuntimeState { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: eviction against the sink cache reads only the sinks and the logger; store and adapter are never reached. return new StructuredAgentSessionHostRuntimeState({ - store: {} as never, - adapter: {} as never + store: {}, + adapter: {}, + logger: recordingStructuredAgentSessionLogger().logger } as never) } @@ -146,6 +151,7 @@ describe('rows the provider emits while closing', () => { await evictStructuredAgentSession({ sessionId, + logger: recordingStructuredAgentSessionLogger().logger, eventSink: sink, adapter: { closeSession: async () => { @@ -227,6 +233,7 @@ describe('eviction against the real sink cache', () => { const sessionId = 'session-reattach' await evictStructuredAgentSession({ sessionId, + logger: recordingStructuredAgentSessionLogger().logger, eventSink: state.eventSinkFor(sessionId), adapter: { closeSession: async () => true } as never, discardSink: () => state.discardEventSink(sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts index 182d2e70192..7dde43d165d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts @@ -25,6 +25,7 @@ import { stopAgentSessionProviderRoot } from './structured-agent-session-provide import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { StructuredAgentSessionStopVerdict } from './structured-agent-session-host-types' import { withTimeout } from '../../../shared/promise-timeout-fallback' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionEvictionContext = { sessionId: string @@ -33,6 +34,7 @@ export type StructuredAgentSessionEvictionContext = { hasProviderChild?: boolean eventSink: DeferredStructuredAgentSessionEventSink adapter: StructuredAgentSessionAdapter + logger: StructuredAgentSessionLogger /** Tells the adapter the released lease is done with, so it drops this child's route and index. * The conversation stays: stopping the agent never closes its journal. */ acknowledgeRelease: () => Promise | void @@ -76,7 +78,10 @@ export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSe try { context.beforeProviderChildStop() } catch { - console.warn('[structured-agent-session] capturing recovery witness failed') + context.logger.warn('capturing a recovery witness before a stop failed', { + scope: 'recovery-witness', + sessionId: context.sessionId + }) } } }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index d1a45fc469a..fca04e1028a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -23,6 +23,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail' @@ -51,6 +52,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts index 06538d78310..38b85ca12d1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts @@ -21,6 +21,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): claude: not signed in' @@ -47,6 +48,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index fdaa5b72d50..c0e78b5a0c5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -24,6 +24,7 @@ import { attachStructuredAgentSession } from './structured-agent-session-attach- import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' // Everything before the journal is out of scope here; what matters is what the orchestration does // when the attach throws after acquisition. @@ -146,6 +147,7 @@ async function workingSession(): Promise<{ const server = new AgentHookServer() const records = new Map([[SESSION, ownerRecord()]]) const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions, getRecord: (sessionId) => records.get(sessionId) ?? null, now: () => 1, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts index b18279392b9..b82499c2830 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -24,6 +24,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -133,6 +134,7 @@ beforeEach(async () => { answerPrompt = vi.fn(async ({ commit }) => commit()) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 5d8a4d0429b..f2f885e0763 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -78,7 +78,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { deps.store.getRecord(sessionId), sessions.get(sessionId)?.journal ), - onError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }), + logger: deps.logger, record: (sessionId) => deps.store.getRecord(sessionId), readChildWork: input.clientDelivery.readChildWork, flushStreamedEvents: input.flushStreamedEvents, @@ -116,7 +116,11 @@ export function createStructuredAgentSessionConversationDelivery(input: { }) .catch((error: unknown) => { wakesQueued.delete(sessionId) - deps.onEventSinkError?.({ sessionId, error }) + deps.logger.warn('waking the delivery loop after a commit failed', { + scope: 'delivery-wake', + sessionId, + error + }) }) } // A chat open before its owner's death was proven revises what its open settled. Queued, never @@ -129,7 +133,13 @@ export function createStructuredAgentSessionConversationDelivery(input: { resettleOpenStructuredAgentSessionConversation(deps, sessionId, sessions.get(sessionId)) ) ) - .catch((error: unknown) => deps.onEventSinkError?.({ sessionId, error })) + .catch((error: unknown) => + deps.logger.warn('resettling an open chat after its owner died failed', { + scope: 'death-evidence-resettle', + sessionId, + error + }) + ) } }) return { @@ -158,8 +168,12 @@ async function settleInterruptedCommands( ): Promise { const fence = structuredAgentSessionConversationFence(deps.store, sessionId) try { - await recoverStructuredRewind(deps.store, sessionId, session.journal, fence) + await recoverStructuredRewind(deps, sessionId, session.journal, fence) } catch (error) { - deps.onEventSinkError?.({ sessionId, error }) + deps.logger.warn('settling an interrupted rewind on open failed', { + scope: 'rewind-recovery', + sessionId, + error + }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 719540da5da..a2f38c2e6bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -47,7 +47,7 @@ export type StructuredAgentSessionLifetimeContext = { } } -type ConversationCloseDeps = Pick & { +type ConversationCloseDeps = Pick & { store: Pick } @@ -70,7 +70,11 @@ export async function abandonQueuedStructuredAgentSessionMessages( .then( () => true, (error: unknown) => { - deps.onEventSinkError?.({ sessionId, error }) + deps.logger.warn('rejecting queued messages of a closed chat failed', { + scope: 'queued-abandon', + sessionId, + error + }) return false } ) @@ -113,7 +117,6 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( const owed = owedProviderChildWindDown(session) session.owesProviderChildWindDown = owed ? { ...owed, cause } : undefined const stopping = session.child - let settlementError: unknown const eviction: StructuredAgentSessionEvictionContext = { sessionId, // The retry must not re-stop a child the adapter already proved gone, so this stays honest. @@ -121,6 +124,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( owesProviderChildWindDown: owed !== undefined, eventSink: context.runtimeState.eventSinkFor(sessionId), adapter: context.deps.adapter, + logger: context.deps.logger, // The adapter settles its own open turn with this, so who asked travels with the stop. stopCause: cause, ...(context.restartWitness @@ -153,15 +157,16 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( pendingSubmissionReason: 'provider_closed_before_acknowledgement', // Only a turn no adapter settled: one with no close, or whose settle threw. verdict: turnVerdictForChildEnd(cause, context.now()), - showUnexpectedExitOutcome: false, - onError: (id, error) => { - settlementError = error - context.deps.onEventSinkError?.({ sessionId: id, error }) - } + showUnexpectedExitOutcome: false }) - if (!settled) { + if (!settled.ok) { + context.deps.logger.warn("settling a closed agent's work failed", { + scope: 'close-settlement', + sessionId, + error: settled.error + }) // Without the cause the log names the step and nothing else. - throw new Error('dead generation work settlement failed', { cause: settlementError }) + throw new Error('dead generation work settlement failed', { cause: settled.error }) } }, releaseLease: async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts index 5f336345b5e..e8421b02893 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts @@ -13,6 +13,8 @@ import { } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const NOW = 1_800_000_000_000 @@ -68,7 +70,8 @@ function runtimeState( adapter: {}, journalDatabase: openTestJournalHostDatabase(stateDirectory), claimKeyId: 'key-1', - probeOwner + probeOwner, + logger: createStructuredAgentSessionLogger() } as StructuredAgentSessionHostDeps return new StructuredAgentSessionHostRuntimeState(deps) } @@ -129,7 +132,7 @@ describe('host runtime-state owner probe', () => { it('does not force-close a provider for transient lease probe errors', async () => { const onEventSinkFailure = vi.fn() - const onEventSinkError = vi.fn() + const log = recordingStructuredAgentSessionLogger() const probeOwner = vi.fn(async () => { throw new Error('lease probe unavailable') }) @@ -144,7 +147,7 @@ describe('host runtime-state owner probe', () => { journalDatabase: openTestJournalHostDatabase(stateDirectory), claimKeyId: 'key-1', probeOwner, - onEventSinkError + logger: log.logger } as unknown as StructuredAgentSessionHostDeps const state = new StructuredAgentSessionHostRuntimeState(deps, onEventSinkFailure) @@ -152,10 +155,15 @@ describe('host runtime-state owner probe', () => { state as unknown as { leaseRenewer: { renewNow: () => Promise } } ).leaseRenewer.renewNow() - expect(onEventSinkError).toHaveBeenCalledWith({ - sessionId: record.sessionId, - error: expect.any(Error) - }) + expect(log.entries).toContainEqual( + expect.objectContaining({ + fields: expect.objectContaining({ + scope: 'lease-renewal', + sessionId: record.sessionId, + error: expect.any(Error) + }) + }) + ) expect(onEventSinkFailure).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts index 63f37a37590..1df3bc34909 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts @@ -26,7 +26,7 @@ export class StructuredAgentSessionHostRuntimeState { now: () => deps.now?.() ?? Date.now(), // Lease/ownership failures are transient and stay on the visible lease-error path. // Only deferred sink I/O failures are terminal and may force-close a provider. - onError: ({ sessionId, error }) => deps.onEventSinkError?.({ sessionId, error }) + logger: deps.logger }) } @@ -67,8 +67,9 @@ export class StructuredAgentSessionHostRuntimeState { mintEventSink(sessionId: string): DeferredStructuredAgentSessionEventSink { const minted: DeferredStructuredAgentSessionEventSink = createDeferredStructuredAgentSessionEventSink({ - onError: (error) => { - this.deps.onEventSinkError?.({ sessionId, error }) + sessionId, + logger: this.deps.logger, + onFailed: (error) => { // Only the session's own sink may force its provider down; an attempt's never is. if (this.eventSinks.get(sessionId) === minted) { this.onEventSinkFailure?.(sessionId, error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts index 8a18040daf3..5c9f5215a95 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts @@ -1,4 +1,5 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' /** * Chat-tab visibility is the deletion funnel: every path that removes a chat as a user-facing @@ -9,6 +10,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' export function setStructuredAgentSessionTabVisibility( host: { deps: { + logger: StructuredAgentSessionLogger store: { setSessionTabVisibility: ( sessionId: string, @@ -25,7 +27,10 @@ export function setStructuredAgentSessionTabVisibility( ): Promise { if (!visible) { void host.restartResume.dismiss([sessionId]).catch(() => { - console.warn('[structured-agent-session] forgetting recovery records on chat close failed') + host.deps.logger.warn('forgetting recovery records on chat close failed', { + scope: 'tab-close-recovery-dismiss', + sessionId + }) }) } return host.deps.store.setSessionTabVisibility(sessionId, visible, tabId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts index aa8692a0c73..e6f62d6d780 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts @@ -13,12 +13,15 @@ import { RESUME_MARKER_RECORD_TIMEOUT_MS, structuredAgentSessionHostTeardownPhases } from './structured-agent-session-host-teardown' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const noop = async (): Promise => undefined describe('structured agent-session host teardown', () => { it('names every phase, so the quit-path order is pinned rather than incidental', () => { const phases = structuredAgentSessionHostTeardownPhases({ + logger: createStructuredAgentSessionLogger(), idleSweep: { dispose: noop }, runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, tasks: { drainAttaches: noop }, @@ -62,6 +65,7 @@ describe('structured agent-session host teardown', () => { it('ends child eviction as soon as every chat has closed, not at its bound', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const evict = structuredAgentSessionHostTeardownPhases({ + logger: createStructuredAgentSessionLogger(), idleSweep: { dispose: noop }, runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, tasks: { drainAttaches: noop }, @@ -86,11 +90,12 @@ describe('structured agent-session host teardown', () => { it('bounds stalled recovery publication without preventing later cleanup', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const log = recordingStructuredAgentSessionLogger() const pending = Promise.withResolvers() const cleaned = vi.fn(async () => {}) const flush = vi.fn(async () => cleaned()) const phases = structuredAgentSessionHostTeardownPhases({ + logger: log.logger, idleSweep: { dispose: cleaned }, runtimeState: { stopLeaseRenewal: () => {}, flushAllEventSinks: flush }, tasks: { drainAttaches: cleaned }, @@ -107,13 +112,10 @@ describe('structured agent-session host teardown', () => { await vi.advanceTimersByTimeAsync(2000) await teardown expect(cleaned).toHaveBeenCalledTimes(4) - expect(warning).toHaveBeenCalledWith( - '[structured-agent-session] recording recovery capsule failed' - ) + expect(log.scopes()).toEqual(['teardown-recovery-capsule']) expect(vi.getTimerCount()).toBe(0) } finally { pending.resolve() - warning.mockRestore() vi.useRealTimers() } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index e6700777227..65bf219cf79 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -17,6 +17,7 @@ import { } from './structured-agent-session-host-lifetime' import { withTimeout } from '../../../shared/promise-timeout-fallback' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionTeardownPhase = { name: string @@ -66,6 +67,7 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { /** Opens this teardown's witnesses; each session's own is taken as eviction stops its child. */ beginResumeMarkers: () => void recordResumeMarkers: () => Promise + logger: StructuredAgentSessionLogger }): StructuredAgentSessionTeardownPhase[] { return [ { @@ -74,7 +76,9 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { try { collaborators.beginResumeMarkers() } catch { - console.warn('[structured-agent-session] capturing recovery witnesses failed') + collaborators.logger.warn('capturing recovery witnesses for teardown failed', { + scope: 'teardown-recovery-witnesses' + }) } } }, @@ -90,7 +94,9 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { run: () => withPhaseTimeout(collaborators.recordResumeMarkers, RESUME_MARKER_RECORD_TIMEOUT_MS).catch( () => { - console.warn('[structured-agent-session] recording recovery capsule failed') + collaborators.logger.warn('recording the recovery capsule at teardown failed', { + scope: 'teardown-recovery-capsule' + }) } ) }, @@ -168,7 +174,8 @@ export async function flushStructuredAgentSessionHost( retainSessionIds ), beginResumeMarkers: () => context.restartResume.beginTeardown(context.trigger), - recordResumeMarkers: context.restartResume.recordMarkers + recordResumeMarkers: context.restartResume.recordMarkers, + logger: context.deps.logger }), sessions: context.sessions, retainSessionIds, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts index 2971ce4f79c..ee0bb2bee92 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts @@ -19,6 +19,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -66,6 +67,7 @@ describe('abandoning a structured agent-session host', () => { setOption: async () => undefined } const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter, probeOwner: async () => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index f29d1b68e69..c7667531110 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -29,6 +29,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const journals = createTrackedJournalOpener() @@ -62,6 +63,7 @@ let root: string let recoveryCapsule: TrackedTestRecoveryCapsule let store: AgentSessionRecordStore let host: StructuredAgentSessionHost +let log: ReturnType let acquire: Mock let releaseAcquisition: Mock> let dispatch: Mock @@ -152,7 +154,9 @@ beforeEach(async () => { setOption = vi.fn(async () => undefined) store = await openTestAgentSessionRecordStore(root) recoveryCapsule = new TrackedTestRecoveryCapsule(root) + log = recordingProductionStructuredAgentSessionLogger() host = new StructuredAgentSessionHost({ + logger: log.logger, store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), @@ -200,6 +204,8 @@ export function hostTestState() { root, store, host, + /** Every entry the beforeEach host logged; a host a test builds itself logs elsewhere. */ + log, acquire, releaseAcquisition, dispatch, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index c2ec1833288..dcf708643e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -17,6 +17,7 @@ import type { import type { AgentSessionAttachParams } from './structured-agent-session-attach' import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' import type { AgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionCaller = { callerKey: string } @@ -121,11 +122,9 @@ export type StructuredAgentSessionHostDeps = { idleSweep?: { intervalMs?: number; idleMs?: number } /** Whether an orchestration dispatch still owns this session's worker; absent answers no. */ hasOpenDispatch?: (record: AgentSessionRecord) => boolean - onEventSinkError?: (input: { sessionId: string; error: unknown }) => void - /** Lease bookkeeping run for startup or a read (the reconcile, or resolving a chat's recovery) - * that refused or threw, once per distinct failure. Startup and the read carry on: the next - * attach or send reconciles and resolves recovery again before it acts. */ - onLeaseReconcileFailure?: (failure: unknown) => void + /** Where every failure the host carries on past is reported. Required: a host without one would + * drop exactly the failures nobody sees in the UI. */ + logger: StructuredAgentSessionLogger /** Every status projection this host publishes. `replay` marks a re-projection of state the host * already knew (restore, an arriving subscriber) rather than a fresh journal edge. */ onSessionStatusChanged?: ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 4b1993ea302..d666e4a5c94 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -28,6 +28,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' let root: string let store: AgentSessionRecordStore @@ -132,6 +133,7 @@ describe('attach', () => { } })) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter(), acquire }, journalDatabase: openTestJournalHostDatabase(root), @@ -562,6 +564,7 @@ describe('restart', () => { ) { store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter(), ...adapterOverrides }, journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 37f4a196610..95cc822bba1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -56,6 +56,7 @@ import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent- import { createStructuredAgentSessionConversationDelivery } from './structured-agent-session-host-delivery' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { wireStructuredAgentSessionQueuedMessages } from './structured-agent-session-queued-wiring' +import * as sessionLogger from './structured-agent-session-logger' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' export class StructuredAgentSessionHost { @@ -68,7 +69,7 @@ export class StructuredAgentSessionHost { this.subscribers.publish(sessionId, journal) this.conversationDelivery.afterCommit(sessionId, journal) }, - onDeliveryError: (sessionId, error) => this.deps.onEventSinkError?.({ sessionId, error }), + logger: sessionLogger.deferredStructuredAgentSessionLogger(() => this.deps.logger), onOpened: (sessionId) => this.queued.drain.schedule(sessionId), now: () => this.now() }) @@ -101,6 +102,8 @@ export class StructuredAgentSessionHost { readonly restartResume: StructuredAgentSessionRestartResume constructor(readonly deps: StructuredAgentSessionHostDeps) { + // Every collaborator reads this copy, so a logger that throws cannot fail what it reports. + this.deps = deps = sessionLogger.withNeverThrowingLogger(deps) this.clientDelivery.watchAtRestCommands(deps.adapter) this.backgroundTasks = new StructuredAgentSessionBackgroundTaskChannel( deps, @@ -158,8 +161,7 @@ export class StructuredAgentSessionHost { ), publishStatus: this.clientDelivery.publishStatusAndSettlement, serialize: (sessionId, task) => this.tasks.trackAttach(this.serialize(sessionId, task)), - now: () => this.now(), - onBarrierError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) + now: () => this.now() }) this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, { ...structuredAgentSessionRestartResumeSurfaces(this, this.now), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index cad1d17d94f..8ff4cdbeb42 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -320,8 +320,14 @@ describe('the idle sweep with no child running (P2-22 ii)', () => { stopAgent, stopStartingAgent: stopAgent, closeConversation, - onError: (_id, error) => { - throw error + // A failed step fails the test. + logger: { + warn: (_message, fields) => { + throw fields.error + }, + error: (_message, fields) => { + throw fields.error + } } }) await sweep.tick() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 14cc0e56030..e9b0e1b45c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -14,6 +14,7 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-qu import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export const STRUCTURED_AGENT_SESSION_IDLE_SWEEP_INTERVAL_MS = 5 * 60_000 export const STRUCTURED_AGENT_SESSION_IDLE_MS = 30 * 60_000 @@ -37,7 +38,7 @@ export type StructuredAgentSessionIdleSweepDeps = { stopAgent: (sessionId: string) => Promise stopStartingAgent: (sessionId: string) => Promise closeConversation: (sessionId: string) => Promise - onError: (sessionId: string, error: unknown) => void + logger: StructuredAgentSessionLogger intervalMs?: number idleMs?: number } @@ -87,7 +88,13 @@ export class StructuredAgentSessionIdleSweep { [...this.deps.sessions.keys()].map((sessionId) => this.deps .serialize(sessionId, () => this.tickUnderSerialize(sessionId)) - .catch((error: unknown) => this.deps.onError(sessionId, error)) + .catch((error: unknown) => + this.deps.logger.warn('an idle sweep step failed', { + scope: 'idle-sweep', + sessionId, + error + }) + ) ) ) } finally { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts index a173a3ad661..5a384ce2bb9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts @@ -25,6 +25,7 @@ import { HOST_TEST_SESSION as SESSION, hostTestMessage } from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const { copy } = vi.hoisted(() => ({ copy: { mismatched: false } })) @@ -93,6 +94,7 @@ async function relaunchWithMismatchedCopy(): Promise }) const store = await openTestAgentSessionRecordStore(relaunched) const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(relaunched), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts index ac4bb5e2f4d..bd7d735abbc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts @@ -16,6 +16,9 @@ import { } from './structured-agent-session-host-test-harness' import { hostTestMessage } from './structured-agent-session-host-test-data' +const STOP_LEDGER_ROW_FAILED = + "[agent-session] stop-ledger-row: writing Stop's ledger row failed; Stop runs without it" + let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost @@ -70,9 +73,10 @@ it('refuses a send as corrupt when SQLite reports damage, and still stops the ag // the caller sees, after the fact. await expect(stop()).rejects.toBe(damaged) expect(cancelTurn).toHaveBeenCalledTimes(1) - expect(warn).toHaveBeenCalledWith("[agent-session] Stop's ledger row skipped:", { + expect(warn).toHaveBeenCalledWith(STOP_LEDGER_ROW_FAILED, { + scope: 'stop-ledger-row', sessionId: expect.any(String), - error: 'database disk image is malformed' + error: expect.objectContaining({ message: 'database disk image is malformed' }) }) // The restart-offer withdrawal the attach started holds its lock until it ends. await hostTestRecoveryCapsuleSettled() @@ -93,9 +97,10 @@ it.each([ value: { turnId: 'turn-1', cancelled: true } }) expect(cancelTurn).toHaveBeenCalledTimes(1) - expect(warn).toHaveBeenCalledWith("[agent-session] Stop's ledger row skipped:", { + expect(warn).toHaveBeenCalledWith(STOP_LEDGER_ROW_FAILED, { + scope: 'stop-ledger-row', sessionId: expect.any(String), - error: error.message + error }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 44615a27ee8..f91fc57c055 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -29,6 +29,7 @@ import { import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -95,6 +96,7 @@ beforeEach(async () => { closeSession = vi.fn(async () => true) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire: vi.fn(async ({ fence }) => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts index 9399e4d0242..c3eb1e324e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts @@ -9,6 +9,7 @@ import { import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const NOW = 1_800_000_000_000 const roots: string[] = [] @@ -103,6 +104,7 @@ describe('structured agent-session lease renewal', () => { ) ) const renewer = new StructuredAgentSessionLeaseRenewer({ + logger: recordingStructuredAgentSessionLogger().logger, store: { listRecords: () => records, renewLeases } as unknown as AgentSessionRecordStore, probe: vi.fn(), probeMany, @@ -144,7 +146,7 @@ describe('structured agent-session lease renewal', () => { } return records[0]! }) - const onError = vi.fn() + const log = recordingStructuredAgentSessionLogger() const renewer = new StructuredAgentSessionLeaseRenewer({ store: { listRecords: () => records, @@ -156,14 +158,15 @@ describe('structured agent-session lease renewal', () => { matchedOn: ['spawn-token' as const] }), now: () => NOW + 10_000, - onError + logger: log.logger }) await renewer.renewNow() expect(renewLeases).toHaveBeenCalledOnce() expect(renewLease).toHaveBeenCalledTimes(2) - expect(onError).toHaveBeenCalledWith({ + expect(log.entries.map((entry) => entry.fields)).toContainEqual({ + scope: 'lease-renewal', sessionId: 'session-b', error: expect.objectContaining({ message: 'agent_session_checkpoint_stale' }) }) @@ -174,6 +177,7 @@ describe('structured agent-session lease renewal', () => { const store = await liveStore() let now = NOW const renewer = new StructuredAgentSessionLeaseRenewer({ + logger: recordingStructuredAgentSessionLogger().logger, store, probe: async () => ({ outcome: 'identity-matched', @@ -203,6 +207,7 @@ describe('structured agent-session lease renewal', () => { releaseProbe = resolve }) const renewer = new StructuredAgentSessionLeaseRenewer({ + logger: recordingStructuredAgentSessionLogger().logger, store, probe: async () => { await probing @@ -228,6 +233,7 @@ describe('structured agent-session lease renewal', () => { matchedOn: ['process-start-time' as const] })) const renewer = new StructuredAgentSessionLeaseRenewer({ + logger: recordingStructuredAgentSessionLogger().logger, store, probe, now: () => NOW + 10_000 @@ -241,18 +247,19 @@ describe('structured agent-session lease renewal', () => { it('stops extending the lease when child proof is no longer sufficient', async () => { const store = await liveStore() - const onError = vi.fn() + const log = recordingStructuredAgentSessionLogger() const renewer = new StructuredAgentSessionLeaseRenewer({ store, probe: async () => ({ outcome: 'indeterminate', reason: 'probe unavailable' }), now: () => NOW + 10_000, - onError + logger: log.logger }) await renewer.renewNow() expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW) - expect(onError).toHaveBeenCalledWith({ + expect(log.entries.map((entry) => entry.fields)).toContainEqual({ + scope: 'lease-renewal', sessionId: 'session-renewal', error: expect.any(Error) }) @@ -271,6 +278,7 @@ describe('structured agent-session lease renewal', () => { matchedOn: ['process-start-time' as const] })) const renewer = new StructuredAgentSessionLeaseRenewer({ + logger: recordingStructuredAgentSessionLogger().logger, store, probe, now: () => NOW + 10_000 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts index 21d79020270..7888d4867e2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts @@ -4,13 +4,14 @@ import { AGENT_SESSION_LEASE_TTL_MS, type AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' const RENEW_INTERVAL_MS = Math.floor(AGENT_SESSION_LEASE_TTL_MS / 3) export class StructuredAgentSessionLeaseRenewer { private timer: ReturnType | null = null private running = false - /** The tick in flight. Never rejects: the timer path reports renewal failures through `onError`, + /** The tick in flight. Never rejects: the timer path logs renewal failures, * and stopping must not turn one into a teardown failure as well. */ private inFlight: Promise = Promise.resolve() @@ -22,7 +23,7 @@ export class StructuredAgentSessionLeaseRenewer { records: readonly AgentSessionRecord[] ) => Promise> now: () => number - onError?: (input: { sessionId: string; error: unknown }) => void + logger: StructuredAgentSessionLogger intervalMs?: number } ) {} @@ -106,7 +107,7 @@ export class StructuredAgentSessionLeaseRenewer { if (result.status === 'rejected') { const renewal = renewals[index] if (renewal) { - this.input.onError?.({ sessionId: renewal.sessionId, error: result.reason }) + this.reportFailure(renewal.sessionId, result.reason) } } }) @@ -126,15 +127,24 @@ export class StructuredAgentSessionLeaseRenewer { if (result.status === 'fulfilled') { probes.set(record.sessionId, result.value) } else { - this.input.onError?.({ sessionId: record.sessionId, error: result.reason }) + this.reportFailure(record.sessionId, result.reason) } } return probes } catch (error) { for (const record of records) { - this.input.onError?.({ sessionId: record.sessionId, error }) + this.reportFailure(record.sessionId, error) } return new Map() } } + + /** Lease and ownership failures are transient: the next tick, attach or send retries them. */ + private reportFailure(sessionId: string, error: unknown): void { + this.input.logger.warn('renewing a chat lease failed', { + scope: 'lease-renewal', + sessionId, + error + }) + } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index 706d0d8fc3f..c25640cc81c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -24,6 +24,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -42,6 +43,7 @@ let dispatch: Mock function openHost(): void { host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-log-repeats.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-log-repeats.ts new file mode 100644 index 00000000000..d805a2191d2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-log-repeats.ts @@ -0,0 +1,54 @@ +// Keeps one failure that recurs on a timer (lease renewal, idle sweep) or on every publication +// (status feed) from flooding the shared, rotated trace file and erasing the history around it. + +/** How long a repeated entry stays quiet after it is written. */ +export const STRUCTURED_AGENT_SESSION_LOG_REPEAT_WINDOW_MS = 5 * 60_000 +const MAX_TRACKED_REPEATS = 256 + +type TrackedRepeat = { writtenAt: number; suppressed: number } + +export type StructuredAgentSessionLogRepeats = { + /** `null` swallows this entry; a number writes it, carrying the repeats swallowed since the last. */ + admit: (key: string) => number | null +} + +export function createStructuredAgentSessionLogRepeats(options?: { + now?: () => number + windowMs?: number + maxTracked?: number +}): StructuredAgentSessionLogRepeats { + const now = options?.now ?? Date.now + const windowMs = options?.windowMs ?? STRUCTURED_AGENT_SESSION_LOG_REPEAT_WINDOW_MS + const maxTracked = options?.maxTracked ?? MAX_TRACKED_REPEATS + // Insertion order is write order, so the first key is the one written longest ago. + const tracked = new Map() + + const makeRoom = (at: number): void => { + for (const [key, repeat] of tracked) { + if (at - repeat.writtenAt >= windowMs) { + tracked.delete(key) + } + } + const oldest = tracked.keys().next() + if (tracked.size >= maxTracked && !oldest.done) { + tracked.delete(oldest.value) + } + } + + return { + admit: (key) => { + const at = now() + const repeat = tracked.get(key) + if (repeat && at - repeat.writtenAt < windowMs) { + repeat.suppressed += 1 + return null + } + tracked.delete(key) + if (tracked.size >= maxTracked) { + makeRoom(at) + } + tracked.set(key, { writtenAt: at, suppressed: 0 }) + return repeat?.suppressed ?? 0 + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-logger-test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-logger-test-support.ts new file mode 100644 index 00000000000..f4413c4d11b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-logger-test-support.ts @@ -0,0 +1,58 @@ +import { + createStructuredAgentSessionLogger, + type StructuredAgentSessionLogFields, + type StructuredAgentSessionLogger +} from './structured-agent-session-logger' + +export type RecordedStructuredAgentSessionLog = { + level: keyof StructuredAgentSessionLogger + message: string + fields: StructuredAgentSessionLogFields +} + +/** A logger that keeps every entry, for tests that assert a failure was reported. */ +export function recordingStructuredAgentSessionLogger(): { + logger: StructuredAgentSessionLogger + entries: RecordedStructuredAgentSessionLog[] + scopes: () => string[] +} { + const entries: RecordedStructuredAgentSessionLog[] = [] + return { + logger: { + warn: (message, fields) => entries.push({ level: 'warn', message, fields }), + error: (message, fields) => entries.push({ level: 'error', message, fields }) + }, + entries, + scopes: () => entries.map((entry) => entry.fields.scope) + } +} + +/** A recording logger that also prints as production does, for a harness whose tests read either: + * the entries hold every level and every field, whatever the console mirror keeps. */ +export function recordingProductionStructuredAgentSessionLogger(): ReturnType< + typeof recordingStructuredAgentSessionLogger +> { + const recording = recordingStructuredAgentSessionLogger() + const production = createStructuredAgentSessionLogger() + return { + ...recording, + logger: { + warn: (message, fields) => { + recording.logger.warn(message, fields) + production.warn(message, fields) + }, + error: (message, fields) => { + recording.logger.error(message, fields) + production.error(message, fields) + } + } + } +} + +/** What an event sink built outside a host needs: the session it writes for, and a logger. */ +export function testEventSinkLogging(sessionId = 'session-1'): { + sessionId: string + logger: StructuredAgentSessionLogger +} { + return { sessionId, logger: recordingStructuredAgentSessionLogger().logger } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-logger.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-logger.test.ts new file mode 100644 index 00000000000..0cacbda8587 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-logger.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { _resetTracerForTests, setActiveSink } from '../../observability/tracer' +import { + AgentSessionRefusalError, + agentSessionRefusalError, + refuse +} from '../../../shared/agent-session-wire-refusals' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { + STRUCTURED_AGENT_SESSION_LOG_REPEAT_WINDOW_MS as WINDOW_MS, + createStructuredAgentSessionLogRepeats +} from './structured-agent-session-log-repeats' + +let push: Mock<(record: unknown) => void> +let clock: number + +function written(): { name: string; attributes: Record; exit: unknown }[] { + // As the sink writes them: one JSON line per span. + return JSON.parse(JSON.stringify(push.mock.calls.map(([span]) => span))) +} + +beforeEach(() => { + push = vi.fn<(record: unknown) => void>() + clock = 1_000_000 + setActiveSink({ push, flush: () => {}, close: () => {} }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + vi.spyOn(console, 'error').mockImplementation(() => undefined) +}) + +afterEach(() => { + _resetTracerForTests() + vi.restoreAllMocks() +}) + +describe('a failure that repeats', () => { + it('is written once per window, then once more carrying how many repeats it swallowed', () => { + const logger = createStructuredAgentSessionLogger({ now: () => clock }) + const renew = (): void => + logger.warn('renewing a chat lease failed', { + scope: 'lease-renewal', + sessionId: 'session-1', + error: new Error('database is locked') + }) + + for (let tick = 0; tick < 30; tick += 1) { + renew() + clock += 10_000 + } + expect(written()).toHaveLength(1) + expect(written()[0]?.attributes).not.toHaveProperty('suppressed') + expect(console.warn).toHaveBeenCalledOnce() + + clock += WINDOW_MS + renew() + expect(written()).toHaveLength(2) + expect(written()[1]?.attributes).toMatchObject({ suppressed: 29, sessionId: 'session-1' }) + expect(console.warn).toHaveBeenLastCalledWith( + '[agent-session] lease-renewal: renewing a chat lease failed', + expect.objectContaining({ suppressed: 29 }) + ) + + renew() + expect(written()).toHaveLength(2) + }) + + it('never merges different sessions, scopes, levels, messages or errors', () => { + const logger = createStructuredAgentSessionLogger({ now: () => clock }) + for (let round = 0; round < 3; round += 1) { + logger.warn('renewing a chat lease failed', { scope: 'lease-renewal', sessionId: 'a' }) + logger.warn('renewing a chat lease failed', { scope: 'lease-renewal', sessionId: 'b' }) + logger.warn('renewing a chat lease failed', { scope: 'idle-sweep', sessionId: 'a' }) + logger.error('renewing a chat lease failed', { scope: 'lease-renewal', sessionId: 'a' }) + logger.warn('a different step failed', { scope: 'lease-renewal', sessionId: 'a' }) + logger.warn('a different step failed', { + scope: 'lease-renewal', + sessionId: 'a', + error: new Error('disk full') + }) + } + + expect(written().map((span) => [span.name, span.attributes['sessionId']])).toEqual([ + ['agentSession.lease-renewal', 'a'], + ['agentSession.lease-renewal', 'b'], + ['agentSession.idle-sweep', 'a'], + ['agentSession.lease-renewal', 'a'], + ['agentSession.lease-renewal', 'a'], + ['agentSession.lease-renewal', 'a'] + ]) + }) + + it('writes a failure whose cause, refusal reason or value differs, and swallows a true repeat', () => { + const logger = createStructuredAgentSessionLogger({ now: () => clock }) + // A refusal's message is its bare code: only the cause tells these two apart. + const unreadable = (cause: Error): Error => + new AgentSessionRefusalError( + refuse( + 'agent_session_journal_unreadable', + { reason: 'journalUnavailable' }, + 'agent_session_journal_unreadable' + ), + { cause } + ) + const report = (error: unknown): void => + logger.warn('recording an opened chat tab failed', { + scope: 'tab-visibility-open', + sessionId: 'session-1', + error + }) + const denied = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }) + const failing = Object.assign(new Error('EIO: i/o error'), { code: 'EIO' }) + + report(unreadable(denied)) + report(unreadable(denied)) + report(unreadable(failing)) + report( + agentSessionRefusalError('agent_session_journal_unreadable', { reason: 'journalCorrupt' }) + ) + report({ code: 'busy', attempt: 1 }) + report({ attempt: 1, code: 'busy' }) + report({ code: 'locked', attempt: 1 }) + + expect( + written().map((span) => span.attributes['errorCause'] ?? span.attributes['error']) + ).toEqual([ + ['Error: EACCES: permission denied'], + ['Error: EIO: i/o error'], + undefined, + { code: 'busy', attempt: 1 }, + { code: 'locked', attempt: 1 } + ]) + expect(written()[2]?.attributes).toMatchObject({ refusalReason: 'journalCorrupt' }) + expect(written()[0]?.attributes).toMatchObject({ refusalReason: 'journalUnavailable' }) + }) + + it('writes one message under another error name, or as a refusal, as separate failures', () => { + const logger = createStructuredAgentSessionLogger({ now: () => clock }) + const renew = (error: Error): void => + logger.warn('renewing a chat lease failed', { scope: 'lease-renewal', sessionId: 'a', error }) + const unreadableRecord = (): Error => new Error('execution_owner_reconciling') + const reconciling = (): Error => + agentSessionRefusalError('execution_owner_reconciling', { reason: 'hostReconciling' }) + + for (let tick = 0; tick < 3; tick += 1) { + renew(unreadableRecord()) + renew(reconciling()) + renew(new TypeError('execution_owner_reconciling')) + } + + expect(written().map((span) => span.exit)).toEqual([ + expect.objectContaining({ cause: expect.stringMatching(/^Error: /) }), + expect.objectContaining({ cause: expect.stringMatching(/^AgentSessionRefusalError: /) }), + expect.objectContaining({ cause: expect.stringMatching(/^TypeError: /) }) + ]) + }) + + it('tracks a bounded number of repeating entries', () => { + const repeats = createStructuredAgentSessionLogRepeats({ now: () => clock, maxTracked: 2 }) + expect(repeats.admit('a')).toBe(0) + expect(repeats.admit('b')).toBe(0) + expect(repeats.admit('a')).toBeNull() + // A third key evicts the one written longest ago, so `a` is written again. + expect(repeats.admit('c')).toBe(0) + expect(repeats.admit('a')).toBe(0) + expect(repeats.admit('c')).toBeNull() + }) +}) + +describe('what an Error leaves in the trace file', () => { + it('carries its code and its causes, depth-capped, by name and message only', () => { + const denied = Object.assign(new Error('EACCES: permission denied, open'), { + code: 'EACCES', + path: '/private/payload' + }) + const locked = Object.assign(new Error('database is locked'), { + code: 'ERR_SQLITE_ERROR', + errcode: 5 + }) + const chain = new Error('agent_session_store_corrupt', { + cause: new Error('read failed', { + cause: new Error('third', { cause: new Error('fourth', { cause: new Error('fifth') }) }) + }) + }) + + const logger = createStructuredAgentSessionLogger({ now: () => clock }) + logger.warn('importing legacy records failed', { scope: 'legacy-record-import', error: chain }) + logger.warn('opening the journal failed', { + scope: 'journal-database-open', + error: new Error('opening failed', { cause: denied }) + }) + logger.error('writing events failed', { scope: 'journal-event-sink', error: locked }) + + const [corrupt, open, sqlite] = written() + expect(corrupt?.attributes['errorCause']).toEqual([ + 'Error: read failed', + 'Error: third', + 'Error: fourth' + ]) + expect(open?.attributes['errorCause']).toEqual(['Error: EACCES: permission denied, open']) + expect(JSON.stringify(written())).not.toContain('/private/payload') + expect(sqlite?.attributes).toMatchObject({ errorCode: 'ERR_SQLITE_ERROR', errorErrcode: 5 }) + expect(sqlite?.exit).toMatchObject({ cause: expect.stringContaining('database is locked') }) + }) + + it('names the code of a cause whose message does not', () => { + const logger = createStructuredAgentSessionLogger({ now: () => clock }) + logger.warn('x failed', { + scope: 'x', + error: new Error('x', { cause: Object.assign(new Error('no space'), { code: 'ENOSPC' }) }) + }) + expect(written()[0]?.attributes['errorCause']).toEqual(['Error: no space [ENOSPC]']) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-logger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-logger.ts new file mode 100644 index 00000000000..1244e974532 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-logger.ts @@ -0,0 +1,173 @@ +// Where the structured chat host and its runtime report a failure they carry on past. +// +// One required dependency rather than a callback per failure: a host built without it does not +// compile, so no failure path can quietly drop what went wrong. The default writes each entry to +// the host's local trace file (the desktop's `/logs/main.trace.ndjson`, collected by the +// diagnostic bundle; orcad's `/logs/orcad.trace.ndjson`) and to the console. + +import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { startSpan } from '../../observability/tracer' +import { createStructuredAgentSessionLogRepeats } from './structured-agent-session-log-repeats' + +export type StructuredAgentSessionLogFields = { + /** The step that failed; a stable name a log search can find. */ + readonly scope: string + readonly sessionId?: string + readonly error?: unknown + readonly [field: string]: unknown +} + +export type StructuredAgentSessionLogger = { + warn: (message: string, fields: StructuredAgentSessionLogFields) => void + error: (message: string, fields: StructuredAgentSessionLogFields) => void +} + +type LogLevel = keyof StructuredAgentSessionLogger + +const guarded = new WeakSet() + +/** Reporting is bookkeeping: a logger that throws must never fail the operation it reports. */ +export function neverThrowingStructuredAgentSessionLogger( + logger: StructuredAgentSessionLogger +): StructuredAgentSessionLogger { + if (guarded.has(logger)) { + return logger + } + const call = + (level: LogLevel) => + (message: string, fields: StructuredAgentSessionLogFields): void => { + try { + logger[level](message, fields) + } catch (loggerError) { + try { + console.warn(`[agent-session] ${message}`, { ...fields, loggerError }) + } catch { + // Nothing is left to report to. + } + } + } + const safe: StructuredAgentSessionLogger = { warn: call('warn'), error: call('error') } + guarded.add(safe) + return safe +} + +/** A copy of `deps` whose logger cannot throw; every collaborator built from it inherits that. */ +export function withNeverThrowingLogger( + deps: T +): T { + return { ...deps, logger: neverThrowingStructuredAgentSessionLogger(deps.logger) } +} + +/** The logger `resolve` returns at each call, for a collaborator built before the host's deps. */ +export function deferredStructuredAgentSessionLogger( + resolve: () => StructuredAgentSessionLogger +): StructuredAgentSessionLogger { + return { + warn: (message, fields) => resolve().warn(message, fields), + error: (message, fields) => resolve().error(message, fields) + } +} + +/** The production logger: a failed span in the local trace file, plus the console. A repeat of + * the same entry is written at most once per window, carrying how many were swallowed. */ +export function createStructuredAgentSessionLogger(options?: { + now?: () => number +}): StructuredAgentSessionLogger { + const repeats = createStructuredAgentSessionLogRepeats({ now: options?.now }) + const write = + (level: LogLevel) => + (message: string, fields: StructuredAgentSessionLogFields): void => { + const { scope, error, ...rest } = fields + const attributes = { level, message, ...rest, ...errorAttributes(error) } + // Keyed on all the entry writes but its stack: only a failure writing the same entry repeats. + const suppressed = repeats.admit( + stableJson([ + scope, + attributes, + error instanceof Error ? `${error.name}: ${error.message}` : null + ]) + ) + if (suppressed === null) { + return + } + const span = startSpan(`agentSession.${scope}`, { + attributes: suppressed > 0 ? { ...attributes, suppressed } : attributes + }) + span.fail(error instanceof Error ? error : message) + const print = level === 'error' ? console.error : console.warn + print( + `[agent-session] ${scope}: ${message}`, + suppressed > 0 ? { ...fields, suppressed } : fields + ) + } + return neverThrowingStructuredAgentSessionLogger({ warn: write('warn'), error: write('error') }) +} + +const MAX_LOGGED_CAUSES = 3 + +/** The span's failure carries an Error's name, message and stack; its code and causes ride here. + * A value that is not an Error is written whole, as it would serialize. */ +function errorAttributes(error: unknown): Record { + if (error === undefined) { + return {} + } + if (!(error instanceof Error)) { + return { error } + } + const causes: string[] = [] + let cause: unknown = error.cause + // Name and message only: a cause's own fields can carry what the failing step was handling. + while (cause !== undefined && causes.length < MAX_LOGGED_CAUSES) { + if (cause instanceof Error) { + causes.push(`${cause.name}: ${cause.message}${codeSuffix(cause)}`) + cause = cause.cause + } else { + causes.push(typeof cause === 'object' && cause !== null ? '[non-error cause]' : String(cause)) + cause = undefined + } + } + const code = errorCode(error) + // node:sqlite's `code` is one generic value; `errcode` tells SQLITE_BUSY from SQLITE_FULL. + const sqliteCode = codeValue('errcode' in error ? error.errcode : undefined) + // A refusal's message is its bare code; its reason is wire-safe and tells refusals apart. + const details = isAgentSessionRefusalError(error) ? error.refusal.details : undefined + const reason = details && 'reason' in details ? details.reason : undefined + return { + ...(code !== undefined ? { errorCode: code } : {}), + ...(sqliteCode !== undefined ? { errorErrcode: sqliteCode } : {}), + ...(typeof reason === 'string' ? { refusalReason: reason } : {}), + ...(causes.length > 0 ? { errorCause: causes } : {}) + } +} + +/** The same value always renders the same: object keys sorted, and no throw on a cycle. */ +function stableJson(value: unknown): string { + try { + return JSON.stringify(value, (_key, inner: unknown) => { + if (typeof inner === 'bigint') { + return String(inner) + } + if (typeof inner !== 'object' || inner === null || Array.isArray(inner)) { + return inner + } + return Object.fromEntries( + Object.entries(inner).sort(([left], [right]) => (left < right ? -1 : 1)) + ) + }) + } catch { + return String(value) + } +} + +function errorCode(error: Error): string | number | undefined { + return codeValue('code' in error ? error.code : undefined) +} + +function codeValue(value: unknown): string | number | undefined { + return typeof value === 'string' || typeof value === 'number' ? value : undefined +} + +function codeSuffix(error: Error): string { + const code = errorCode(error) + return code !== undefined && !error.message.includes(String(code)) ? ` [${code}]` : '' +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts index aa4a9547001..9ab31743cf4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts @@ -37,6 +37,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const PROVIDER_ROW = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1' } @@ -62,6 +63,7 @@ beforeEach(async () => { dispatch = vi.fn(async () => ({ state: 'admitted' as const })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire: async (input) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 3e96a02ce26..6199531c8ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -38,6 +38,7 @@ import type { MutationPlan } from './structured-agent-session-mutation-plans' import { runSettledAgentSessionMutation } from './structured-agent-session-operation-settlement' import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' // The code is shared with the client so a read that refuses this way can be told apart from a // transcript that failed to load; the two must never drift apart. @@ -61,6 +62,7 @@ export type AgentSessionMutationSessionPreparation = export type AgentSessionMutationRequest = { store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter + logger: StructuredAgentSessionLogger callerKey: string envelope: AgentSessionMutationEnvelope plan: MutationPlan @@ -127,7 +129,7 @@ export async function admitAndRunAgentSessionMutation( admitted = await request.store.admitMutationOperation(operation) } catch (error) { if (plan.runsWithoutLedgerRow) { - admitted = admitWithoutLedgerRow(request.store, operation, error) + admitted = admitWithoutLedgerRow(request, operation, error) ledgerRowWritten = false } else if ( isAgentSessionRefusalError(error) || @@ -198,13 +200,14 @@ export async function admitAndRunAgentSessionMutation( /** The committed ledger's admission, placing nothing: a failed commit left memory as it was. */ function admitWithoutLedgerRow( - store: AgentSessionRecordStore, + { store, logger }: Pick, 'store' | 'logger'>, operation: AgentSessionMutationOperationAdmission, error: unknown ): AgentSessionMutationOperationDecision { - console.warn("[agent-session] Stop's ledger row skipped:", { + logger.warn("writing Stop's ledger row failed; Stop runs without it", { + scope: 'stop-ledger-row', sessionId: operation.envelope.sessionId, - error: error instanceof Error ? error.message : String(error) + error }) const evaluated = store.evaluateMutationOperation(operation) if (!evaluated) { @@ -231,6 +234,7 @@ function turnContext( journal, fence, adapter: request.adapter, + logger: request.logger, ...(persistedOptions ? { persistedOptions } : {}), persistOptions: (options) => request.store diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index 73b47f1fe6e..01059d67ceb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -55,6 +55,7 @@ export function mutateStructuredAgentSession( admitAndRunAgentSessionMutation({ store: context.deps.store, adapter: context.deps.adapter, + logger: context.deps.logger, callerKey: caller.callerKey, envelope, plan, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts index 71750505fef..50c887c85df 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts @@ -17,9 +17,11 @@ import { } from './structured-agent-session-host-test-data' import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { sendPlan } from './structured-agent-session-mutation-plans' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' async function context(): Promise { return { + logger: createStructuredAgentSessionLogger(), sessionId: SESSION, journal: await journals.open({ identity: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts index e4675bfefb1..24de6ff708b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts @@ -52,14 +52,23 @@ export async function runSettledAgentSessionMutation(input: { if (input.plan.markUnknownBeforeRun && error instanceof AgentSessionPreDispatchError) { throw error } + const { logger, sessionId } = input.context try { await settle({ status: 'unknown' }) } catch { // Bookkeeping must not replace the operation's proof of whether dispatch began. - console.warn('[structured-agent-session] operation uncertainty persistence failed') + logger.warn('recording an operation as unknown failed', { + scope: 'operation-unknown-settlement', + sessionId, + operationId: input.envelope.clientOperationId + }) } if (outcome && !outcome.ok) { - console.warn('[structured-agent-session] refused operation settlement failed') + logger.warn('recording a refused operation failed', { + scope: 'operation-refused-settlement', + sessionId, + operationId: input.envelope.clientOperationId + }) return outcome } throw error diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts index 7700a616b38..47aecdaa00c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts @@ -6,14 +6,16 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { ensureStructuredAgentSessionAgentForOperation } from './structured-agent-session-agent-start' import { recordStructuredAgentSessionOptionIntent } from './structured-agent-session-options-read' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const SESSION = 'session-1' describe('an operation whose agent start throws', () => { it('is refused as a failed restart, with the error only in the log', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const log = recordingStructuredAgentSessionLogger() const cause = new Error('EACCES: permission denied, open /Users/me/.orca/leases.json') const context = { + deps: { logger: log.logger }, sessions: new Map(), reconcileLeases: () => Promise.reject(cause) } @@ -31,8 +33,9 @@ describe('an operation whose agent start throws', () => { message: "The agent couldn't restart." } }) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('starting the agent'), cause) - warn.mockRestore() + expect(log.entries.map((entry) => entry.fields)).toEqual([ + { scope: 'operation-agent-start', sessionId: SESSION, error: cause } + ]) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index aa7b8a17295..a544e9e1761 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -21,6 +21,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const DEFAULT_MODEL = 'gpt-default' @@ -124,6 +125,7 @@ beforeEach(async () => { store = await openTestAgentSessionRecordStore(root) router = adapter() host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: router, journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 106998ccfed..e17d5ddf074 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -117,7 +117,7 @@ export async function readStructuredAgentSessionOptions( const { adapter, store } = context.deps const live = await context.serialize(sessionId, async () => { const session = await context.openConversation(sessionId).catch((error: unknown) => { - throw journalOpenReadRefusal(error) + throw journalOpenReadRefusal(error, context.deps.logger, sessionId) }) const child = session?.child if (!child) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index 24e9dc66f44..8b2586a9ccb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -32,6 +32,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const SWEEP_MS = 5 @@ -93,6 +94,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index db9406785f8..1e171e64199 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -17,6 +17,7 @@ import { import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { performAttach } from './structured-agent-session-attach-flow' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const NOW = 1_800_000_000_000 const SESSION = 'session-alpha' @@ -82,6 +83,7 @@ async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { const input = { store, adapter, + logger: createStructuredAgentSessionLogger(), journalDatabase: openTestJournalHostDatabase(root), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root)), authority: { @@ -157,8 +159,11 @@ describe('a create that fails before any process spawns', () => { } // What failed is kept for the log. expect(warn).toHaveBeenCalledWith( - '[agent-session] provider start failed:', - expect.objectContaining({ message: raw }) + '[agent-session] provider-start: starting the provider failed', + expect.objectContaining({ + scope: 'provider-start', + error: expect.objectContaining({ message: raw }) + }) ) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index 9df12ea96fd..c45ceac238c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -15,6 +15,7 @@ import { import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { performAttach } from './structured-agent-session-attach-flow' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const NOW = 1_800_000_000_000 const SESSION = 'session-alpha' @@ -82,6 +83,7 @@ describe('processless structured session reservation', () => { await expect( performAttach({ + logger: createStructuredAgentSessionLogger(), store, adapter, openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), @@ -126,6 +128,7 @@ describe('processless structured session reservation', () => { })) } as unknown as StructuredAgentSessionAdapter const input = { + logger: createStructuredAgentSessionLogger(), store, adapter, journalDatabase: openTestJournalHostDatabase(root), @@ -163,6 +166,7 @@ describe('processless structured session reservation', () => { const acquire = vi.fn() const adapter = { supportsCreate, acquire } as unknown as StructuredAgentSessionAdapter const input = { + logger: createStructuredAgentSessionLogger(), store, adapter, journalDatabase: openTestJournalHostDatabase(root), @@ -215,6 +219,7 @@ describe('processless structured session reservation', () => { await expect( performAttach({ + logger: createStructuredAgentSessionLogger(), store, adapter, openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), @@ -280,6 +285,7 @@ describe('processless structured session reservation', () => { releaseAcquisition: vi.fn(async () => true) } as unknown as StructuredAgentSessionAdapter const input = { + logger: createStructuredAgentSessionLogger(), store, adapter, journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts index 1dd8fb73416..8d67ba0aa7c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -11,6 +11,7 @@ import { type StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' import type { AgentSessionPromptCancelRoute } from './structured-agent-session-adapter-stop' @@ -81,6 +82,7 @@ function context( flushStreamedEvents: () => Promise ): AgentSessionTurnContext { return { + logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', journal, fence: 1, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 230709e5d21..e387d2a5e99 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -40,6 +40,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { @@ -83,6 +84,7 @@ const spawnStartingChild: StructuredAgentSessionAdapter['acquire'] = async (inpu function startHost(): void { host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index 411e148c586..de2951a6292 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -16,6 +16,7 @@ import { import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CLAUDE_SESSION = 'claude-session' const hosts: StructuredAgentSessionHost[] = [] @@ -56,6 +57,7 @@ function createHost( probeOwner?: StructuredAgentSessionHost['deps']['probeOwner'] ): StructuredAgentSessionHost { const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: claudeAdapter(), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index f5e0e14b09f..2922c3fdb2e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -23,6 +23,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const INIT_DELAY_MS = 40 @@ -75,6 +76,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, // The production router is what declares create support; the bare adapter only knows locations. adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index 8f3c0390aaa..acc2e6488c3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts @@ -24,7 +24,6 @@ export type StructuredAgentSessionProviderStartedContext = { serialize: (sessionId: string, task: () => Promise) => Promise now: () => number publishStatus?: (sessionId: string) => void - onBarrierError: (sessionId: string, error: unknown) => void } export function settleStructuredAgentSessionProviderStarted( @@ -46,7 +45,11 @@ export function settleStructuredAgentSessionProviderStarted( try { await persistStartedOptions(context, event) } catch (error) { - context.onBarrierError(event.sessionId, error) + context.deps.logger.warn('recording what a started provider reported failed', { + scope: 'provider-started-options', + sessionId: event.sessionId, + error + }) } finally { context.publishStatus?.(event.sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index d63af3ba7d2..a9a1089cc1c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } @@ -54,6 +55,7 @@ export async function createQueuedMessageTestRig() { let events: StructuredAgentSessionEventSink | undefined const store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire: async ({ fence, spawnToken, events: sink }) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index b6d9d2a90be..856134f04a9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -30,6 +30,7 @@ import { structuredAgentSessionHostInstance, structuredQueuePause } from './structured-agent-session-queued-pause' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' /** Budget at accept, in the send schema's own unit (`Buffer.byteLength` of the * serialized blocks); refused readably rather than trimmed. */ @@ -263,7 +264,7 @@ export type QueuedMessageDrainDeps = { conversationFence: (sessionId: string) => number /** The consumed submission is ordinary #22821 work from here on. */ wakeDelivery: (sessionId: string) => void - onError: (sessionId: string, error: unknown) => void + logger: StructuredAgentSessionLogger } /** @@ -312,7 +313,11 @@ export class StructuredAgentSessionQueuedMessageDrain { }) .catch((error: unknown) => { this.scheduled.delete(sessionId) - this.deps.onError(sessionId, error) + this.deps.logger.warn('draining queued messages failed', { + scope: 'queued-drain', + sessionId, + error + }) }) } @@ -326,7 +331,11 @@ export class StructuredAgentSessionQueuedMessageDrain { if (journal.queuedMessages.settlementOwed() || journal.queuedMessages.deliveredByEchoOwed()) { // A live per-row hook was skipped; heal now, before a draft sends, rather than at reopen. await journal.queuedMessages.settleOwed().catch((error: unknown) => { - this.deps.onError(sessionId, error) + this.deps.logger.warn('settling owed queued-message bookkeeping failed', { + scope: 'queued-settle-owed', + sessionId, + error + }) }) } const next = oldestActionableQueuedMessage(journal) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index 5b7f4bdf16c..288848a96a6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -127,9 +127,11 @@ export async function carryQueuedMessagesToClearReplacement( operationId: input.operationId }) } catch (error) { - console.warn("[agent-session] /clear's queued-draft carry skipped:", { + ctx.logger.warn("carrying queued drafts to /clear's replacement failed", { + scope: 'clear-queued-carry', sessionId: ctx.sessionId, - error: error instanceof Error ? error.message : String(error) + replacementSessionId: input.replacementSessionId, + error }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts index 1428de75fe6..f96716d0304 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts @@ -15,6 +15,7 @@ import { randomUUID } from 'node:crypto' import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { QueuePauseFact } from '../agent-session-journal/queued-message-pause-table' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' /** A per-process id, minted once per host process like the runtime's own * `runtimeId` (`orca-runtime-runtime-id.ts`); a draft written by another @@ -74,7 +75,8 @@ export function structuredQueuePause(journal: PauseJournal): AgentSessionQueuePa */ export async function retireEndedQueuePause( sessionId: string, - journal: PauseJournal + journal: PauseJournal, + logger: StructuredAgentSessionLogger ): Promise { try { const stop = journal.queuedMessages.pause() @@ -88,9 +90,10 @@ export async function retireEndedQueuePause( adoptInto: adopt ? hostInstance : null }) } catch (error) { - console.warn("[agent-session] a started turn's queue-pause retirement skipped:", { + logger.warn('retiring a queue pause a started turn ended failed', { + scope: 'queue-pause-retirement', sessionId, - error: error instanceof Error ? error.message : String(error) + error }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts index 16a9b4aeaf7..c715f5accd9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -59,8 +59,10 @@ export async function runStopWithQueuePause( } function report(ctx: AgentSessionTurnContext, step: string, error: unknown): void { - console.warn(`[agent-session] Stop's ${step} skipped:`, { + ctx.logger.warn(`Stop's ${step} failed`, { + scope: 'stop-queued-bookkeeping', sessionId: ctx.sessionId, - error: error instanceof Error ? error.message : String(error) + step, + error }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts index 2b193d5cb82..f3861f97166 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts @@ -15,6 +15,7 @@ import { resumeStructuredAgentQueue, sendQueuedStructuredAgentMessage } from './structured-agent-session-queued-mutations' +import { deferredStructuredAgentSessionLogger } from './structured-agent-session-logger' /** `sessions` are the live conversations (their `touch` is the idle sweep's activity renewal, * which the drain's schedule rides); everything else comes from the host's mutation context, @@ -33,7 +34,8 @@ export function wireStructuredAgentSessionQueuedMessages( conversationFence: (sessionId) => structuredAgentSessionConversationFence(context().deps.store, sessionId), wakeDelivery: (sessionId) => context().wakeDelivery(sessionId), - onError: (sessionId, error) => context().deps.onEventSinkError?.({ sessionId, error }) + // Read lazily, like the rest of this wiring: the host's deps are not assigned yet. + logger: deferredStructuredAgentSessionLogger(() => context().deps.logger) }) return { drain, @@ -44,7 +46,7 @@ export function wireStructuredAgentSessionQueuedMessages( sessions.touch(sessionId) const journal = sessions.get(sessionId)?.journal if (journal && !journal.isReadOnly) { - void retireEndedQueuePause(sessionId, journal) + void retireEndedQueuePause(sessionId, journal, context().deps.logger) } drain.schedule(sessionId) }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts index 83238e95d74..945f2cefbad 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts @@ -18,6 +18,7 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const SESSION_ID = 'codex_read_restore_fixture' const WORKSPACE_ID = 'repo-1::/tmp/workspace' @@ -55,7 +56,8 @@ let journalRoot: string const openDeps = () => ({ store, journalDatabase: openTestJournalHostDatabase(journalRoot), - adapter: {} + adapter: {}, + logger: recordingStructuredAgentSessionLogger().logger }) const opened: AgentSessionJournal[] = [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts index 09cab618ab9..8f110c3e69a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts @@ -15,6 +15,7 @@ vi.mock('./structured-agent-session-restart-restore', () => ({ })) import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' // The restore pool is mocked; the host database only fills the deps' shape. const stateDirectory = mkdtempSync(join(tmpdir(), 'orca-readable-restorer-')) @@ -36,7 +37,8 @@ describe('StructuredAgentSessionReadableRestorer', () => { openDeps: { store: { getRecord: () => null, listRecords: () => records }, journalDatabase: openTestJournalHostDatabase(stateDirectory), - adapter: {} + adapter: {}, + logger: recordingStructuredAgentSessionLogger().logger }, supportsRecord: () => true, reconcile: async () => true, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index 538fb0b9dbc..a277d9a3d4c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -34,6 +34,7 @@ import { import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { StructuredAgentSessionTurnCompletionFeed } from './structured-agent-session-turn-completion-feed' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const SESSION = 'recovered-turn-session' const THREAD = 'thread-1' @@ -81,6 +82,7 @@ async function sessionWithRunningTurn() { const server = new AgentHookServer() const sessions = new Map([[SESSION, indexedStatusFeedSession({ journal })]]) const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions, getRecord: () => null, now: () => clock, @@ -129,7 +131,7 @@ async function sessionWithRunningTurn() { function settleDeadGeneration( journal: AgentSessionJournal, verdict: StructuredAgentSessionTurnVerdict -): Promise { +): ReturnType { return settleStructuredAgentSessionDeadGeneration({ journal, sessionId: SESSION, @@ -160,7 +162,7 @@ describe('a turn recovery settled after its host went away', () => { async (_label, verdict, outcome, mark) => { const session = await sessionWithRunningTurn() session.recoverAt(RECOVERED) - expect(await settleDeadGeneration(session.journal, verdict)).toBe(true) + expect(await settleDeadGeneration(session.journal, verdict)).toEqual({ ok: true }) session.publish() expect(session.summaries.at(-1)).toMatchObject({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index f69fbad1031..228fa21fbc2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -22,6 +22,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -81,6 +82,7 @@ function adapter(): StructuredAgentSessionAdapter { function openHost(overrides: Partial = {}): void { host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 83082bf923a..d5899c794e3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -29,6 +29,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const METHODS = ['agentSession.setOption', 'agentSession.send'] as const @@ -88,6 +89,7 @@ async function createHarness(options: { attached?: boolean } = {}) { setOption } const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter, journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts index d3c15de933c..4b06198e373 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts @@ -12,6 +12,7 @@ import { type StructuredAgentSessionRevisionResolver } from './structured-agent-session-event-sink' import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate' +import { testEventSinkLogging } from './structured-agent-session-logger-test-support' const ROW: AgentJournalItemIdentity = { provider: 'orca', clientMessageId: 'row' } @@ -61,7 +62,7 @@ const appendSuffix = describe('resolved revisions', () => { it('reads the row as the journal holds it when each queued revision runs', async () => { const rows = new Map() - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const bytes = estimateStructuredAgentSessionItemBytes(ROW, text('abc')) for (const suffix of ['a', 'b', 'c']) { expect( @@ -81,7 +82,7 @@ describe('resolved revisions', () => { it('skips a revision that resolves to nothing', async () => { const rows = new Map() - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.sink.tryReviseResolvedItem?.(1_000, () => null, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) @@ -93,7 +94,7 @@ describe('resolved revisions', () => { it('publishes a revision in the operation that writes it, within the same reservation', async () => { const rows = new Map() - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const bytes = estimateStructuredAgentSessionItemBytes(ROW, text('a')) deferred.sink.tryReviseResolvedItemAndPublish?.(bytes, appendSuffix('a'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE @@ -114,7 +115,7 @@ describe('resolved revisions', () => { it('refuses a resolved write larger than the reservation it was admitted with', async () => { const rows = new Map() - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const bytes = estimateStructuredAgentSessionItemBytes(ROW, text('a')) deferred.sink.tryReviseResolvedItem?.( bytes, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index e441a156b49..0045684ccb3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -33,6 +33,7 @@ import { } from './structured-agent-session-host-test-data' import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' export const REST_TEST_CALLER = { callerKey: 'client-1' } export const IDLE_MS = STRUCTURED_AGENT_SESSION_IDLE_MS @@ -160,6 +161,7 @@ export async function createRestTestRig( } const hostFor = (overrides: Partial) => new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts index 9ded5b54d98..155785ae35e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts @@ -13,6 +13,7 @@ import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' import { marker } from './structured-agent-session-restart-resume-test-harness' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' type Handover = { dispatchState: string; reason?: string | null } | undefined @@ -36,7 +37,7 @@ it('holds each slot from accept until handover or rejection, and frees it when t }, awaitSettlement: async () => ({ dispatchState: 'accepted' }), note: async () => undefined, - onNoteFailed: () => undefined + logger: createStructuredAgentSessionLogger() }) const sessions = Array.from({ length: 8 }, (_, index) => `session-${index}`) const outcomes = resumeStructuredAgentSessionsFromRestart( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts index 0fe36994cd7..973d7758358 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts @@ -10,6 +10,7 @@ import { startStructuredAgentSessionContinuation, type StructuredAgentSessionContinuationDeps } from './structured-agent-session-restart-continuation' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' /** The whole continuation: handed over, then its verdict. */ async function continueStructuredAgentSessionAfterRestart( @@ -45,7 +46,7 @@ function dependencies( : undefined ), note: vi.fn(async () => undefined), - onNoteFailed: vi.fn() + logger: createStructuredAgentSessionLogger() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 8038e90e1fc..27c09bc2bbe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -31,6 +31,7 @@ import { import { AgentSessionPreDispatchError } from './structured-agent-session-operation-settlement' import { restartContinuationEnvelope } from './structured-agent-session-restart-continuation-envelope' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' /** * All four dispatch states are preserved, never collapsed into transport success. @@ -71,7 +72,7 @@ export type StructuredAgentSessionContinuationHost = { sessionId: string, clientMessageId: string ) => Promise<{ value: AgentSessionSendResult } | undefined> - onNoteFailed: (sessionId: string, error: unknown) => void + logger: StructuredAgentSessionLogger now: () => number /** Whether the marker is still an offer. Asked at acceptance, inside the session lock, so the * first message accepted since the restart decides: the user's, or this continuation. */ @@ -103,7 +104,7 @@ export function restartContinuationDeps( agentSessionSendSubmission( (await host.awaitSendHandedOver(sessionId, clientMessageId))?.value ), - onNoteFailed: host.onNoteFailed, + logger: host.logger, note: restartNoteWriter(host) } } @@ -185,9 +186,9 @@ export type StructuredAgentSessionContinuationDeps = { /** Records a host-authored journal note: that this send was Orca's, not the user's, or that the * chat did not carry on. `tone` is a display hint older clients render as plain text. */ note: (sessionId: string, text: string, tone?: 'error' | 'warning') => Promise - /** Reports a note that could not be written. The note is best effort, but its failure is not - * allowed to be silent — a swallowed append is how this regressed unnoticed once already. */ - onNoteFailed: (sessionId: string, error: unknown) => void + /** Where a note that could not be written is reported. The note is best effort, but its failure + * is not allowed to be silent — a swallowed append is how this regressed unnoticed once already. */ + logger: StructuredAgentSessionLogger } /** A continuation handed to its agent, or already decided. */ @@ -232,7 +233,7 @@ export async function startStructuredAgentSessionContinuation( } async function noteOutcome( - deps: Pick, + deps: Pick, sessionId: string, result: StructuredAgentSessionContinuationOutcome ): Promise { @@ -241,8 +242,8 @@ async function noteOutcome( // carry on. Best effort — losing it must not turn a delivered continuation into a failure. try { await deps.note(sessionId, AGENT_SESSION_RESTART_CONTINUATION_NOTE) - } catch (error) { - deps.onNoteFailed(sessionId, error) + } catch { + reportNoteFailed(deps, sessionId) } } else { await noteNotContinued( @@ -260,7 +261,7 @@ async function noteOutcome( /** The chat itself carries the failure, so it survives the toast, a dismissed record and a restart, * and the user's next message is what moves past it. */ async function noteNotContinued( - deps: Pick, + deps: Pick, sessionId: string, outcome: 'refused' | 'not-connected' | 'unconfirmed' ): Promise { @@ -274,8 +275,8 @@ async function noteNotContinued( : AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE, 'error' )) - } catch (error) { - deps.onNoteFailed(sessionId, error) + } catch { + reportNoteFailed(deps, sessionId) } } @@ -295,7 +296,10 @@ async function sendContinuation( throw error } // Persistence can fail after dispatch; a thrown send is not proof of non-delivery. - console.warn('[structured-agent-session] restart continuation send failed') + deps.logger.warn('sending a restart continuation failed', { + scope: 'restart-continuation-send', + sessionId + }) return null }) if (!sent) { @@ -360,3 +364,14 @@ function verdictOf( ? { sessionId, outcome: 'continued' } : { sessionId, outcome: 'unknown' } } + +/** Without the error: a failed append can carry the chat's private recovery payload. */ +function reportNoteFailed( + deps: Pick, + sessionId: string +): void { + deps.logger.warn('writing a restart continuation note failed', { + scope: 'restart-continuation-note', + sessionId + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts index ee769b9e8c3..63722d1737c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts @@ -31,6 +31,7 @@ import { type StructuredAgentSessionResumeOutcome } from './structured-agent-session-restart-resume-runner' import { RESTART_CONTINUATION_SUPERSEDED } from './structured-agent-session-restart-continuation' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' type FailureCapsule = Pick< AgentSessionRecoveryCapsule, @@ -79,6 +80,7 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { retryable: (marker: AgentSessionResumeMarker) => boolean /** Makes the failed chats readable here, so `retryable` reads each one's journal. */ reveal: (markers: readonly AgentSessionResumeMarker[]) => Promise + logger: StructuredAgentSessionLogger now: () => number /** The capsule's single mutation lane, shared with the offer's own operations. */ enqueue: (operation: () => Promise) => Promise @@ -88,7 +90,9 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { return (await deps.capsule?.listFailed(deps.now())) ?? [] } catch { // Recovery is advisory; a malformed capsule must not make ordinary chat actions unusable. - console.warn('[structured-agent-session] reading recovery capsule failed') + deps.logger.warn('reading restart failures from the recovery capsule failed', { + scope: 'recovery-capsule-read' + }) return [] } } @@ -176,21 +180,30 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { await deps .enqueue(() => capsule.completeResume(operationId, completed, deps.now())) .catch(() => { - console.warn('[structured-agent-session] restart offer completion failed') + deps.logger.warn('completing a restart offer failed', { + scope: 'restart-offer-complete', + operationId + }) }) // Filed before the rollback so a failure the user must act on is never reopened as an offer // that would silently re-run it. await deps .enqueue(() => capsule.failResume(operationId, failures, deps.now())) .catch(() => { - console.warn('[structured-agent-session] restart failure record failed') + deps.logger.warn('recording a restart failure failed', { + scope: 'restart-failure-record', + operationId + }) }) // This only reopens rows still owned by this operation. Rows removed by completeResume stay // removed, even when the write of a later bookkeeping step fails. await deps .enqueue(() => capsule.rollbackResume(operationId, deps.now())) .catch(() => { - console.warn('[structured-agent-session] restart offer rollback failed') + deps.logger.warn('rolling back a restart offer failed', { + scope: 'restart-offer-rollback', + operationId + }) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 9bb2173f42f..94746daf3ec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -34,6 +34,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' /** Starts the agent explicitly — the attach a client's ensure makes — for a test that needs a * running child before its next step. Nothing else starts one ahead of a send. */ @@ -124,7 +125,9 @@ export async function interruptedRestart( const closeSession = vi.fn(async () => true) // The relaunch comes after the quit that recorded the offer. const clock = { now: NOW + 1 } + const log = recordingProductionStructuredAgentSessionLogger() const host = new StructuredAgentSessionHost({ + logger: log.logger, store, adapter: { ...adapter(), @@ -154,7 +157,7 @@ export async function interruptedRestart( await readFile(join(previous.root, AGENT_SESSION_RECOVERY_CAPSULE_FILE), 'utf8') ) const marker = parseAgentSessionResumeMarker(capsule.entries[0]?.marker) - return { ...hostTestState(), host, store, closeSession, marker, clock } + return { ...hostTestState(), host, store, log, closeSession, marker, clock } } export async function statusNotes(host: StructuredAgentSessionHost) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-records.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-records.ts index f9530039cd4..95c97be63a9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-records.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-records.ts @@ -3,6 +3,7 @@ import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionRestartOfferRecords = { /** Every pending offer. Read-only; nothing is spent. */ @@ -23,6 +24,7 @@ export function createStructuredAgentSessionRestartOfferRecords(deps: { readFailedMarkers: () => Promise hasSession: (sessionId: string) => boolean reveal: (sessionId: string) => Promise + logger: StructuredAgentSessionLogger now: () => number /** The capsule's single mutation lane, shared with the offer's own operations. */ enqueue: (operation: () => Promise) => Promise @@ -33,7 +35,9 @@ export function createStructuredAgentSessionRestartOfferRecords(deps: { } catch { // Recovery is advisory. A malformed capsule must not make ordinary chat actions unusable; // the durable bytes stay untouched so an explicit dismissal can remove them. - console.warn('[structured-agent-session] reading recovery capsule failed') + deps.logger.warn('reading restart offers from the recovery capsule failed', { + scope: 'recovery-capsule-read' + }) return [] } } @@ -69,7 +73,9 @@ export function createStructuredAgentSessionRestartOfferRecords(deps: { void deps .enqueue(() => capsule.forgetSuperseded(gone, deps.now())) .catch(() => { - console.warn('[structured-agent-session] pruning superseded restart offers failed') + deps.logger.warn('pruning superseded restart offers failed', { + scope: 'restart-offer-prune' + }) }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts index d36e5702d1d..b155674babf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts @@ -29,6 +29,7 @@ import { HOST_TEST_SESSION as SESSION, hostTestMessage } from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' afterEach(() => vi.restoreAllMocks()) @@ -450,6 +451,7 @@ describe('reading the chat against where the offer was taken', () => { } } const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + logger: createStructuredAgentSessionLogger(), // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fact reads only the journal's cursor and submissions and the child fields given here. sessions: new Map([[SESSION, session as never]]), now: () => NOW, @@ -527,6 +529,7 @@ describe('reading the chat against where the offer was taken', () => { } } const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + logger: createStructuredAgentSessionLogger(), // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fact reads only the journal's cursor and submissions and the child fields given here. sessions: new Map([[SESSION, session as never]]), now: () => afterCrash, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts index 76d8006d9a1..4924860b05f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts @@ -13,6 +13,7 @@ import type { AgentSessionResumeMarker } from '../../../shared/agent-session-res import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { isRestartContinuationOf } from './structured-agent-session-restart-continuation-envelope' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionRestartOfferWithdrawal = ReturnType< typeof createStructuredAgentSessionRestartOfferWithdrawal @@ -26,6 +27,7 @@ export type StructuredAgentSessionRestartOfferSession = Pick< export function createStructuredAgentSessionRestartOfferWithdrawal(deps: { sessions: ReadonlyMap capsule?: Pick + logger: StructuredAgentSessionLogger now: () => number /** The capsule's single mutation lane, shared with the offer's own operations. */ enqueue: (operation: () => Promise) => Promise @@ -85,7 +87,10 @@ export function createStructuredAgentSessionRestartOfferWithdrawal(deps: { ) ) .catch(() => { - console.warn('[structured-agent-session] withdrawing a restart offer failed') + deps.logger.warn('withdrawing a restart offer failed', { + scope: 'restart-offer-withdraw', + sessionId + }) }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index 59ff6958d53..a46551b4500 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -34,6 +34,7 @@ import { } from './structured-agent-session-host-test-data' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { inspect } from 'node:util' afterEach(() => vi.useRealTimers()) @@ -53,7 +54,7 @@ it('publishes continuation attribution to the subscribed chat without another pr }) it('reports a failed attribution note without an installed error sink or private details', async () => { - const { host } = await interruptedRestart() + const { host, log } = await interruptedRestart() const append = AgentSessionJournal.prototype.appendItem const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) const write = vi.spyOn(AgentSessionJournal.prototype, 'appendItem').mockImplementation(function ( @@ -69,8 +70,11 @@ it('reports a failed attribution note without an installed error sink or private const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(result.continued).toMatchObject([{ outcome: 'continued' }]) expect(warning).toHaveBeenCalledExactlyOnceWith( - '[structured-agent-session] restart continuation attribution failed' + '[agent-session] restart-continuation-note: writing a restart continuation note failed', + { scope: 'restart-continuation-note', sessionId: SESSION } ) + expect(log.scopes()).toContain('restart-continuation-note') + expect(inspect(log.entries, { depth: 8 })).not.toContain('/private/account') } finally { write.mockRestore() warning.mockRestore() @@ -318,7 +322,7 @@ it('keeps concurrent recovery reads independent and non-destructive', async () = }) it('fails closed on corrupt recovery storage while an ordinary send still works', async () => { - const { host, root, dispatch } = await interruptedRestart() + const { host, root, dispatch, log } = await interruptedRestart() await writeFile(join(root, AGENT_SESSION_RECOVERY_CAPSULE_FILE), '{') const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) expect(await host.restartResume.list()).toEqual([]) @@ -335,9 +339,15 @@ it('fails closed on corrupt recovery storage while an ordinary send still works' await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) // list; the action's read of offers and of failures; the post-action refresh of both. The send // cannot withdraw an offer it cannot read either, and says so. - const withdrawing = '[structured-agent-session] withdrawing a restart offer failed' - await vi.waitFor(() => expect(warning).toHaveBeenLastCalledWith(withdrawing)) - expect(warning.mock.calls.filter(([message]) => message !== withdrawing)).toHaveLength(5) + await vi.waitFor(() => + expect(log.entries.at(-1)).toEqual({ + level: 'warn', + message: 'withdrawing a restart offer failed', + fields: { scope: 'restart-offer-withdraw', sessionId: SESSION } + }) + ) + // Counted before the console's repeat suppression, which prints each repeated read only once. + expect(log.scopes().filter((scope) => scope !== 'restart-offer-withdraw')).toHaveLength(5) warning.mockRestore() }) @@ -451,9 +461,12 @@ it('logs teardown capsule publication failure and still releases the provider', 'agent_session_ownership_unknown' ) expect(warning).toHaveBeenCalledWith( - '[structured-agent-session] recording recovery capsule failed' + '[agent-session] teardown-recovery-capsule: recording the recovery capsule at teardown failed', + { scope: 'teardown-recovery-capsule' } ) - expect(warning.mock.calls.flat().map(String).join(' ')).not.toContain(previous.root) + expect(inspect(warning.mock.calls, { depth: 8 })).not.toContain(previous.root) + expect(previous.log.scopes()).toContain('teardown-recovery-capsule') + expect(inspect(previous.log.entries, { depth: 8 })).not.toContain(previous.root) expect(previous.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') warning.mockRestore() await rm(capsulePath, { recursive: true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts index 41dd7913d85..29f0c9f1047 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -8,6 +8,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { classifyStoreFailure } from './structured-agent-session-attach' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' const MAX_RECONCILIATION_PASSES = 8 @@ -53,8 +54,10 @@ export type ReaderBookkeepingFailures = { clear: () => void } +/** Startup and the read carry on past a failure: the next attach or send reconciles and resolves + * recovery again before it acts. */ export function reportEachFailureOnce( - onFailure: ((failure: unknown) => void) | undefined + logger: StructuredAgentSessionLogger ): ReaderBookkeepingFailures { let reported: string | null = null return { @@ -62,15 +65,10 @@ export function reportEachFailureOnce( const key = failureKey(failure) if (key !== reported) { reported = key - try { - onFailure?.(failure) - } catch (sinkError) { - // A throwing sink must not turn the reported failure back into a failed read. - console.warn('[structured-agent-session] reporting a lease bookkeeping failure failed', { - failure, - sinkError - }) - } + logger.warn('chat lease bookkeeping for a read failed', { + scope: 'lease-reconcile', + error: failure + }) } }, clear: () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index 4da648576ed..441d4e80626 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -18,6 +18,7 @@ vi.mock('./structured-agent-session-read-restore', () => ({ })) import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' // The restore under test is mocked; the host database only fills the deps' shape. const stateDirectory = mkdtempSync(join(tmpdir(), 'orca-restart-restore-')) @@ -29,7 +30,8 @@ afterAll(() => { const NO_OPEN_DEPS = { store: { getRecord: () => null, listRecords: () => [] }, journalDatabase: openTestJournalHostDatabase(stateDirectory), - adapter: {} + adapter: {}, + logger: recordingStructuredAgentSessionLogger().logger } describe('restart journal restoration', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index af2a0970a6f..ef8481e2b1f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -44,6 +44,7 @@ import { import type { StructuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' import { createStructuredAgentSessionRestartWitnesses } from './structured-agent-session-restart-witnesses' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' type LiveSession = StructuredAgentSessionRestartOfferSession @@ -77,6 +78,7 @@ export function createStructuredAgentSessionRestartResume( store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter recoveryCapsule?: AgentSessionRecoveryCapsule + logger: StructuredAgentSessionLogger }, sessions: ReadonlyMap, surfaces: StructuredAgentSessionRestartResumeSurfaces @@ -96,6 +98,7 @@ export function createStructuredAgentSessionRestartResume( const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ sessions, ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), + logger: deps.logger, now: surfaces.now, enqueue: enqueueRecoveryOperation }) @@ -111,6 +114,7 @@ export function createStructuredAgentSessionRestartResume( adapter: deps.adapter, retryable: (marker) => derive([marker], 'may-be-held').candidates.length === 1, reveal: (markers) => revealMarkers(markers), + logger: deps.logger, now: surfaces.now, enqueue: enqueueRecoveryOperation }) @@ -123,6 +127,7 @@ export function createStructuredAgentSessionRestartResume( reveal: async (sessionId) => { await surfaces.revealSession(sessionId).catch(() => null) }, + logger: deps.logger, now: surfaces.now, enqueue: enqueueRecoveryOperation }) @@ -139,6 +144,7 @@ export function createStructuredAgentSessionRestartResume( const continuationHost: StructuredAgentSessionContinuationHost = { ...surfaces, + logger: deps.logger, sessions, conversationFence: (sessionId) => deps.store.getRecord(sessionId) @@ -206,7 +212,10 @@ export function createStructuredAgentSessionRestartResume( await enqueueRecoveryOperation(() => deps.recoveryCapsule!.rollbackResume(operationId, surfaces.now()) ).catch(() => { - console.warn('[structured-agent-session] restart offer rollback failed') + deps.logger.warn('rolling back a restart offer reservation failed', { + scope: 'restart-offer-rollback', + operationId + }) }) } throw error @@ -280,7 +289,9 @@ export function createStructuredAgentSessionRestartResume( remainingCandidates = await list() remainingFailures = await failures.list() } catch { - console.warn('[structured-agent-session] restart offer refresh failed after action') + deps.logger.warn('refreshing restart offers after an action failed', { + scope: 'restart-offer-refresh' + }) } return { resumed, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts index bb9eb500d1d..2284367c14b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts @@ -1,8 +1,7 @@ // Binds the restart-resume surface to the host's own capabilities. // // Its own file because the bindings carry real decisions — which caller key the continuation sends -// under, that the verdict comes from the settlement waiter rather than the send result, and where a -// failed journal note is reported — and those belong next to the collaborator that consumes them +// under, that the verdict comes from the settlement waiter rather than the send result, — and those belong next to the collaborator that consumes them // rather than buried in the host constructor. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' @@ -30,7 +29,6 @@ export type StructuredAgentSessionRestartResumeSurfaces = { sessionId: string, clientMessageId: string ) => Promise<{ value: AgentSessionSendResult } | undefined> - onNoteFailed: (sessionId: string, error: unknown) => void /** The session's child records, the host's one read of them. */ readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined now: () => number @@ -79,8 +77,6 @@ export function structuredAgentSessionRestartResumeSurfaces( until: 'handed-over', budgetMs: MAX_TIMER_DELAY_MS }), - onNoteFailed: () => - console.warn('[structured-agent-session] restart continuation attribution failed'), now } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts index 35425a70afe..6cde8d87b84 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts @@ -27,6 +27,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -57,6 +58,7 @@ function adapter(): StructuredAgentSessionAdapter { function createHost(store: AgentSessionRecordStore): StructuredAgentSessionHost { const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts index d611bdb1589..3d920db766a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts @@ -16,12 +16,13 @@ import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD } from './structured-agent-session-host-test-data' +import { inspect } from 'node:util' it.each(['beginTeardown', 'captureBeforeStop', 'recordMarkers'] as const)( 'keeps private %s failures out of logs while completing teardown', async (method) => { await attach() - const { host, store } = hostTestState() + const { host, store, log } = hostTestState() const failure = new Error('private recovery payload at /private/account/session.json') const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) const operation = vi.spyOn(host.restartResume, method).mockImplementation(() => { @@ -30,14 +31,27 @@ it.each(['beginTeardown', 'captureBeforeStop', 'recordMarkers'] as const)( try { await expect(host.flushAllStreamedEvents()).resolves.toBeUndefined() expect(operation).toHaveBeenCalledOnce() - expect(warning).toHaveBeenCalledExactlyOnceWith( + expect(warning).toHaveBeenCalledOnce() + expect(warning.mock.calls[0]?.[0]).toBe( { - beginTeardown: '[structured-agent-session] capturing recovery witnesses failed', - captureBeforeStop: '[structured-agent-session] capturing recovery witness failed', - recordMarkers: '[structured-agent-session] recording recovery capsule failed' + beginTeardown: + '[agent-session] teardown-recovery-witnesses: capturing recovery witnesses for teardown failed', + captureBeforeStop: + '[agent-session] recovery-witness: capturing a recovery witness before a stop failed', + recordMarkers: + '[agent-session] teardown-recovery-capsule: recording the recovery capsule at teardown failed' }[method] ) - expect(warning.mock.calls.flat().map(String).join(' ')).not.toContain(failure.message) + expect(inspect(warning.mock.calls, { depth: 8 })).not.toContain(failure.message) + // Every level and every field the logger received, not only what its console mirror printed. + expect(log.scopes()).toContain( + { + beginTeardown: 'teardown-recovery-witnesses', + captureBeforeStop: 'recovery-witness', + recordMarkers: 'teardown-recovery-capsule' + }[method] + ) + expect(inspect(log.entries, { depth: 8 })).not.toContain('/private/account') expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') await expect(host.journalSnapshot(SESSION)).rejects.toThrow('agent_session_ownership_unknown') } finally { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index 2ea475cf3ba..51ab878e2c8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -38,6 +38,8 @@ import { createStructuredAgentSessionConversationLifetime } from './structured-a import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import { createStructuredAgentSessionRestartOfferWithdrawal } from './structured-agent-session-restart-offer-withdrawal' import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const { readOnlyOpens, openReadOnly } = vi.hoisted(() => ({ readOnlyOpens: new Array(), @@ -216,7 +218,7 @@ type LifetimeHost = Parameters undefined, - onDeliveryError: () => undefined, + logger: recordingStructuredAgentSessionLogger().logger, now: () => clock }) } @@ -228,7 +230,13 @@ async function restore(sessionIds: readonly string[]) { getRecord: (sessionId: string) => recordFor(sessionId), listRecords: () => sessionIds.map(recordFor) } as unknown as AgentSessionRecordStore - const deps = { store, adapter: {}, journalDatabase: openTestJournalHostDatabase(root) } + const log = recordingStructuredAgentSessionLogger() + const deps = { + store, + adapter: {}, + journalDatabase: openTestJournalHostDatabase(root), + logger: log.logger + } await restoreStructuredAgentSessionsOnRestart({ openDeps: deps, records: sessionIds.map(recordFor), @@ -254,7 +262,7 @@ async function restore(sessionIds: readonly string[]) { readChildWork: () => undefined }) const lifetime = lifetimeOver(sessions, async (sessionId) => sessions.get(sessionId) ?? null) - return { sessions, lifetime, lifetimeOver } + return { sessions, lifetime, lifetimeOver, log } } function texts(items: readonly { body: unknown }[]): string { @@ -377,6 +385,7 @@ describe('startup restore of chats still in their per-chat files', () => { const { sessions } = await restore(['chat-a']) const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + logger: createStructuredAgentSessionLogger(), sessions, now: () => clock, enqueue: (operation) => operation() @@ -426,12 +435,11 @@ describe('startup restore of chats still in their per-chat files', () => { 'refuses a read of the chat restore %s when its copy meets damage, never with the storage text', async (reach) => { await seedLegacyChat('chat-a') - const { sessions, lifetime, lifetimeOver } = await restore(['chat-a']) + const { sessions, lifetime, lifetimeOver, log } = await restore(['chat-a']) const restored = sessions.get('chat-a')! await rm(legacyDirFor('chat-a'), { recursive: true, force: true }) await mkdir(legacyDirFor('chat-a'), { recursive: true }) await writeFile(legacyFile('chat-a'), 'not a database, and never was one') - vi.spyOn(console, 'warn').mockImplementation(() => undefined) const reader = reach === 'listed' ? lifetime : lifetimeOver(conversations(), async () => restored) @@ -442,6 +450,12 @@ describe('startup restore of chats still in their per-chat files', () => { message: 'agent_session_journal_unreadable', refusal: { details: { reason: 'journalCorrupt' } } }) + // The storage text the reader never sees goes to the host's log. + expect(log.entries.filter((entry) => entry.fields.scope === 'open-for-read')).toEqual([ + expect.objectContaining({ + fields: { scope: 'open-for-read', sessionId: 'chat-a', error: expect.any(Error) } + }) + ]) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts index 262c9e18d3c..3046aa971f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -72,7 +72,7 @@ export function createStructuredAgentSessionHostRestore( restoreReadableSessions: (sessionIds?: readonly string[]) => Promise } { const { reconcileLeases, resolveRecovery, ...rest } = wiring - const failures = reportEachFailureOnce(deps.onLeaseReconcileFailure) + const failures = reportEachFailureOnce(deps.logger) const reconcile = createReaderReconcile(reconcileLeases, failures) const restorer = new StructuredAgentSessionReadableRestorer({ openDeps: deps, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts index dd878ebe0d7..c7240eccd83 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const caller = { callerKey: 'desktop' } const KEPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'kept', ordinal: 0 } @@ -78,6 +79,7 @@ function adapter(): StructuredAgentSessionAdapter { function openHost(): StructuredAgentSessionHost { return new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(directory), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index 35e7d5af2e2..6cc37ccb724 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -29,6 +29,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const caller = { callerKey: 'desktop' } let directory: string @@ -92,6 +93,7 @@ beforeEach(async () => { closeSession: async () => true } host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter, journalDatabase: openTestJournalHostDatabase(directory), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index 6358cdfe0f0..f2a1c088e26 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -36,6 +36,7 @@ export async function rewindStructuredAgentSession( const result = await admitAndRunAgentSessionMutation({ store, adapter: context.deps.adapter, + logger: context.deps.logger, callerKey: caller.callerKey, envelope: params.envelope, // Only the provider can do this, so an agent at rest is started first. @@ -212,7 +213,7 @@ export async function rewindStructuredAgentSession( }) const journal = context.sessions.get(sessionId)!.journal await attachContext.runtimeState.flushEventSink(sessionId) - await recoverStructuredRewind(store, sessionId, journal, fence) + await recoverStructuredRewind(context.deps, sessionId, journal, fence) context.publish(sessionId, journal) return { ok: true, value: { itemId: params.itemId, epoch: journal.cursor().epoch } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts index 61696b573c0..bcfeadb6727 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts @@ -5,6 +5,7 @@ import { openConversationForWrite, structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' function withCommand(command: AgentSessionConversationCommandRecord) { return { ...agentSessionRecordFixture(), conversationCommand: command } @@ -76,9 +77,13 @@ describe('a write whose conversation the host could not open', () => { function refusedBy(error: unknown) { vi.spyOn(console, 'warn').mockImplementation(() => {}) - return openConversationForWrite(async () => { - throw error - }, ENVELOPE) + return openConversationForWrite( + async () => { + throw error + }, + ENVELOPE, + createStructuredAgentSessionLogger() + ) } it('says a corrupt history is final, in words and not the error', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts index 1925c13378e..48ceef370e7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts @@ -9,6 +9,8 @@ import { createTrackedJournalOpener } from '../agent-session-journal/journal-hos import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performSend, type AgentSessionTurnContext } from './structured-agent-session-turns' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const journals = createTrackedJournalOpener() @@ -56,6 +58,7 @@ describe('structured send idempotency', () => { const result = await performSend( { + logger: recordingStructuredAgentSessionLogger().logger, sessionId: 'session-1', journal, fence: 2, @@ -97,6 +100,7 @@ describe('structured send idempotency', () => { } })) const context: AgentSessionTurnContext = { + logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', journal, fence: 1, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts index c85c628d917..c30c33d1f43 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts @@ -25,6 +25,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ function eventually(assertion: () => unknown): Promise { @@ -70,6 +71,7 @@ async function relaunchAfterCrashMidTurn( throw new Error('claude: command not found') }) const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter(), acquire }, journalDatabase: openTestJournalHostDatabase(relaunched), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index 86873faf6b0..cf746e29b74 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -71,6 +71,10 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: { + warn: (_message, fields) => hostErrors.push(fields.error), + error: (_message, fields) => hostErrors.push(fields.error) + }, store, adapter: { acquire, @@ -84,8 +88,7 @@ beforeEach(async () => { journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - now: () => NOW, - onEventSinkError: ({ error }) => hostErrors.push(error) + now: () => NOW }) expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) }) @@ -407,8 +410,6 @@ describe('a send with no live owner', () => { acquire.mockRejectedValue( new CodexAppServerRequestError('thread/resume', -32600, `thread/resume failed: ${said}`, said) ) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const id = await accept(sendParams('after the thread went away')) // The sentence names no cause and quotes nothing; Codex's words ride in the fact for Details. @@ -426,11 +427,11 @@ describe('a send with no live owner', () => { "Codex couldn't restart. Send your message to try again." ]) // Orca's own text is logged once where the start failed. - expect(warn).toHaveBeenCalledWith( - '[agent-session] provider start failed:', - expect.objectContaining({ message: `thread/resume failed: ${said}` }) - ) - warn.mockRestore() + expect( + hostErrors.filter( + (error) => error instanceof Error && error.message === `thread/resume failed: ${said}` + ) + ).toHaveLength(1) }) it('restarts again for a Retry under a new id, and replays a resend of the same id', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index be29089991b..4b2011a02ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -24,6 +24,7 @@ import { } from './structured-agent-session-mutation-admission' import { rewindRefusal } from './structured-rewind-refusal' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' /** Why the record refuses any send right now, whoever owns it; null when a send may run. */ export function structuredAgentSessionSendBlock( @@ -56,7 +57,8 @@ export function structuredAgentSessionSendBlock( /** The conversation a send or a Stop writes to, opened when this host holds it closed. */ export async function openConversationForWrite( openConversation: (sessionId: string) => Promise, - envelope: AgentSessionMutationEnvelope + envelope: AgentSessionMutationEnvelope, + logger: StructuredAgentSessionLogger ): Promise { try { if (await openConversation(envelope.sessionId)) { @@ -64,26 +66,34 @@ export async function openConversationForWrite( } return { ok: false, refusal: AGENT_SESSION_NOT_ATTACHED } } catch (error) { - console.warn('[agent-session] opening the conversation for a write failed:', error) + logger.warn('opening the conversation for a write failed', { + scope: 'open-for-write', + sessionId: envelope.sessionId, + error + }) return { ok: false, refusal: journalOpenRefusal(error) } } } /** The conversation a write lands in, opened when this host holds it closed. */ export function openForWrite( - context: Pick, + context: Pick, envelope: AgentSessionMutationEnvelope ): () => Promise { - return () => openConversationForWrite(context.openConversation, envelope) + return () => openConversationForWrite(context.openConversation, envelope, context.deps.logger) } /** For an operation only the provider can perform: the conversation, then its agent. */ export function openWithAgent( - context: Pick, + context: Pick, envelope: AgentSessionMutationEnvelope ): () => Promise { return async () => { - const opened = await openConversationForWrite(context.openConversation, envelope) + const opened = await openConversationForWrite( + context.openConversation, + envelope, + context.deps.logger + ) return opened.ok ? context.ensureAgent(envelope.sessionId) : opened } } @@ -95,7 +105,11 @@ export function sendPreparation( envelope: AgentSessionMutationEnvelope ): () => Promise { return async () => { - const opened = await openConversationForWrite(context.openConversation, envelope) + const opened = await openConversationForWrite( + context.openConversation, + envelope, + context.deps.logger + ) const phase = context.deps.store.getRecord(envelope.sessionId)?.rewind?.phase return opened.ok && (phase === 'prepared' || phase === 'provider-succeeded') ? context.ensureAgent(envelope.sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index 21e50e4dca6..8c5e609b971 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -29,6 +29,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -146,6 +147,7 @@ beforeEach(async () => { dispatch = vi.fn(async () => ({ state: 'admitted' as const })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 17bb4b0ee1f..f14393df874 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -107,6 +108,7 @@ beforeEach(async () => { dispatch = vi.fn(async () => accepted()) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), @@ -134,6 +136,7 @@ describe('settled attach retry', () => { .mockRejectedValueOnce(new Error('journal path unavailable')) .mockResolvedValue(null) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter(), historyFilePath }, journalDatabase: openTestJournalHostDatabase(root), @@ -191,6 +194,7 @@ describe('settled attach retry', () => { }) const mintSpawnToken = vi.fn(() => 'spawn-safe') host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), @@ -235,6 +239,7 @@ describe('settled attach retry', () => { let token = 0 const mintSpawnToken = vi.fn(() => `spawn-${++token}`) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), @@ -265,6 +270,7 @@ describe('settled attach retry', () => { await host.flushAllStreamedEvents() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), @@ -322,6 +328,7 @@ describe('settled attach retry', () => { await host.flushAllStreamedEvents() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index e5527d1a5af..79227670238 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -27,6 +27,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -120,6 +121,7 @@ beforeEach(async () => { ) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire: vi.fn(async ({ fence, spawnToken }) => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index 3c4096be138..8235d0d6705 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -10,6 +10,7 @@ import { type StructuredAgentSessionUnexpectedExitContext, type StructuredAgentSessionUnexpectedExitSession } from './structured-agent-session-unexpected-exit' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const SESSION = 'session-1' const GENERATION = 'generation-1' @@ -47,6 +48,7 @@ function contextFor(session: StructuredAgentSessionUnexpectedExitSession) { }) ) const context: StructuredAgentSessionUnexpectedExitContext = { + logger: recordingStructuredAgentSessionLogger().logger, store: { getRecord: () => record, transitionHandoff: async ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts index d4f8bce0033..b71022b92c7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -81,8 +81,17 @@ async function relaunch( await seedTestAgentSessionStoreFromNewerBuild(relaunched) } const store = await openTestAgentSessionRecordStore(relaunched) - const onLeaseReconcileFailure = vi.fn() + // The lease-reconcile entries the host logs, by the failure each reports. + const leaseReconcileLogged = vi.fn() const host = new StructuredAgentSessionHost({ + logger: { + warn: (_message, fields) => { + if (fields.scope === 'lease-reconcile') { + leaseReconcileLogged(fields.error) + } + }, + error: () => undefined + }, store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(relaunched), @@ -92,30 +101,29 @@ async function relaunch( stopOwnerProcess: () => { throw new Error('an owner not proven alive must not be stopped') }, - now: () => NOW, - onLeaseReconcileFailure + now: () => NOW }) replaceHostTestState({ store, host }) - return { host, store, stateDirectory: relaunched, onLeaseReconcileFailure } + return { host, store, stateDirectory: relaunched, leaseReconcileLogged } } it('reports a startup reconcile whose store write fails, and does not reject', async () => { - const { host, store, onLeaseReconcileFailure } = await relaunch() + const { host, store, leaseReconcileLogged } = await relaunch() writes.failing = true await expect(host.reconcileRestartLeases()).resolves.toBeUndefined() - expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() - expect(onLeaseReconcileFailure).toHaveBeenCalledWith(IO_ERROR) + expect(leaseReconcileLogged).toHaveBeenCalledOnce() + expect(leaseReconcileLogged).toHaveBeenCalledWith(IO_ERROR) // Nothing was adjudicated, so the lease still grants no writer. expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(true) }) it('reconciles the chat on its next send once the store can be written again', async () => { - const { host, store, onLeaseReconcileFailure } = await relaunch() + const { host, store, leaseReconcileLogged } = await relaunch() writes.failing = true await host.reconcileRestartLeases() - expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(leaseReconcileLogged).toHaveBeenCalledOnce() writes.failing = false const body = hostTestMessage('sent after a startup reconcile failed') @@ -128,13 +136,13 @@ it('reconciles the chat on its next send once the store can be written again', a timeout: 10_000 }) expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(false) - expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(leaseReconcileLogged).toHaveBeenCalledOnce() // Before the relaunched directory is removed, so the child's wind-down can write its lease. await host.flushAllStreamedEvents() }) it('adjudicates records a newer Orca wrote in memory only, and writes nothing', async () => { - const { host, store, stateDirectory, onLeaseReconcileFailure } = await relaunch(true) + const { host, store, stateDirectory, leaseReconcileLogged } = await relaunch(true) expect(store.readOnly).toBe(true) const path = journalDatabasePath(stateDirectory) const bytes = await readFile(path) @@ -142,7 +150,7 @@ it('adjudicates records a newer Orca wrote in memory only, and writes nothing', await expect(host.reconcileRestartLeases()).resolves.toBeUndefined() - expect(onLeaseReconcileFailure).not.toHaveBeenCalled() + expect(leaseReconcileLogged).not.toHaveBeenCalled() expect(store.getRecord(SESSION)?.lease).toMatchObject({ unreconciled: false, claimStatus: 'released' @@ -152,7 +160,7 @@ it('adjudicates records a newer Orca wrote in memory only, and writes nothing', }) it('restores a chat for reading while the reconcile keeps failing, and reports it once', async () => { - const { host, store, onLeaseReconcileFailure } = await relaunch() + const { host, store, leaseReconcileLogged } = await relaunch() writes.failing = true await host.reconcileRestartLeases() @@ -160,8 +168,8 @@ it('restores a chat for reading while the reconcile keeps failing, and reports i expect(host.hasSession(SESSION)).toBe(true) expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(true) - expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() - expect(onLeaseReconcileFailure).toHaveBeenCalledWith(IO_ERROR) + expect(leaseReconcileLogged).toHaveBeenCalledOnce() + expect(leaseReconcileLogged).toHaveBeenCalledWith(IO_ERROR) }) it('refuses a send over records a newer Orca wrote with the update words', async () => { @@ -182,20 +190,20 @@ it('refuses a send over records a newer Orca wrote with the update words', async }) it('restores a chat for reading from records a newer Orca wrote', async () => { - const { host, stateDirectory, onLeaseReconcileFailure } = await relaunch(true) + const { host, stateDirectory, leaseReconcileLogged } = await relaunch(true) const path = journalDatabasePath(stateDirectory) const bytes = await readFile(path) await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() expect(host.hasSession(SESSION)).toBe(true) - expect(onLeaseReconcileFailure).not.toHaveBeenCalled() + expect(leaseReconcileLogged).not.toHaveBeenCalled() expect(await readFile(path)).toEqual(bytes) }) // A chat whose owner could not be proven gone is left recovering; the next attach or send retries it. it('restores a chat for reading when resolving its recovery cannot write the store', async () => { - const { host, store, onLeaseReconcileFailure } = await relaunch(false, async () => ({ + const { host, store, leaseReconcileLogged } = await relaunch(false, async () => ({ outcome: 'indeterminate', reason: 'probe' })) @@ -207,17 +215,17 @@ it('restores a chat for reading when resolving its recovery cannot write the sto expect(host.hasSession(SESSION)).toBe(true) expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') - expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() - expect(onLeaseReconcileFailure).toHaveBeenCalledWith(IO_ERROR) + expect(leaseReconcileLogged).toHaveBeenCalledOnce() + expect(leaseReconcileLogged).toHaveBeenCalledWith(IO_ERROR) }) it.each([ ['startup reconcile', (host: StructuredAgentSessionHost) => host.reconcileRestartLeases()], ['read restore', (host: StructuredAgentSessionHost) => host.restoreReadableSessions([SESSION])] -])('keeps the %s resolving when the failure sink throws', async (_step, read) => { - const { host, onLeaseReconcileFailure } = await relaunch() +])('keeps the %s resolving when the logger throws', async (_step, read) => { + const { host, leaseReconcileLogged } = await relaunch() const sinkError = new Error('error sink failed') - onLeaseReconcileFailure.mockImplementation(() => { + leaseReconcileLogged.mockImplementation(() => { throw sinkError }) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -225,10 +233,10 @@ it.each([ try { await expect(read(host)).resolves.toBeUndefined() - expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(leaseReconcileLogged).toHaveBeenCalledOnce() expect(warn).toHaveBeenCalledWith( - expect.stringContaining('reporting a lease bookkeeping failure failed'), - expect.objectContaining({ failure: IO_ERROR, sinkError }) + expect.stringContaining('chat lease bookkeeping for a read failed'), + expect.objectContaining({ error: IO_ERROR, loggerError: sinkError }) ) } finally { warn.mockRestore() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts index 03c490742ce..cfb801c8fd6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts @@ -10,6 +10,7 @@ import { type StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const SESSION = 'status-session' const EVIDENCE: AgentChildWorkEvidence[] = [{ type: 'session-ended', observedAt: 5 }] @@ -38,6 +39,7 @@ async function feedWith(sink: StructuredAgentSessionStatusSink) { }) const session = indexedStatusFeedSession({ journal }) return new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([[SESSION, session]]), getRecord: () => null, now: () => 1_000, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts index 545a0ebb460..c6777d8a07a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts @@ -6,6 +6,7 @@ import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const SESSION = 'status-session' const journals = createTrackedJournalOpener() @@ -42,6 +43,7 @@ it('publishes each provider child even when a replacement has the same startup p setChild({ phase: 'starting', generation: 'child-1', fence: 1 }) const events: AgentSessionStatusEvent[] = [] const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions, getRecord: () => null, now: () => 1 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts index c4e8100c30a..30ddeea9678 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts @@ -22,6 +22,7 @@ import { indexedStatusFeedSession, statusFeedChildView as childView } from './structured-agent-session-status-feed-test-session' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const SESSION = 'status-session' const USER_IDENTITY = { @@ -75,6 +76,7 @@ async function feedWithChildren(provider: AgentSessionHandleProvider = 'codex') } let now = 1_000 const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([[SESSION, indexedStatusFeedSession({ journal, provider })]]), getRecord: () => null, now: () => (now += 1), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts index c3922852245..09a3442985b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts @@ -14,6 +14,7 @@ import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wi import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const SESSION = 'clock-session' const THREAD = 'thread-1' @@ -60,6 +61,7 @@ async function openFeed() { stateDirectory: join(root, SESSION) }) const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]), getRecord: () => null, now: () => clock diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index 8062b740bcb..579be82e0c9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -23,6 +23,8 @@ import { type StructuredAgentSessionStatusFeedDeps, type StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { testEventSinkLogging } from './structured-agent-session-logger-test-support' const SESSION = 'status-session' const TURN_IDENTITY = { @@ -79,6 +81,7 @@ function feedFor( statusSink ?? (readChildWork ? { publish: () => {}, forget: () => {}, readChildWork } : undefined) const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), ...(onStatusChanged ? { onStatusChanged } : {}), ...(sink ? { statusSink: () => sink } : {}), sessions: { @@ -546,7 +549,7 @@ describe('StructuredAgentSessionStatusFeed', () => { const { feed } = feedFor(new Map([[SESSION, { journal }]]), null, (summary) => seen.push(summary.status) ) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) if (agent === 'claude') { const translator = createClaudeJournalTranslator({ sink: deferred.sink }) translator.handle({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 4f717d2c31b..ffb6767a321 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -28,6 +28,10 @@ import { type StructuredAgentSessionStatusState } from './structured-agent-session-status-journal-projection' import { structuredStatusSummariesEqual } from './structured-agent-session-status-summary-equality' +import { + deferredStructuredAgentSessionLogger, + type StructuredAgentSessionLogger +} from './structured-agent-session-logger' import { StructuredAgentSessionStatusOwnership, type StructuredAgentSessionStatusSink @@ -52,6 +56,8 @@ export type StructuredAgentSessionStatusFeedDeps = { sessions: ReadonlyMap getRecord: (sessionId: string) => AgentSessionRecord | null now: () => number + /** Where a failing sink or observer is reported; neither may cost subscribers their event. */ + logger: StructuredAgentSessionLogger /** Every projection change, whether or not anyone is subscribed. `replay` marks a re-projection * of state the host already knew (restore, an arriving subscriber) rather than a journal edge. */ onStatusChanged?: (summary: AgentSessionStatusSummary, options: { replay: boolean }) => void @@ -72,6 +78,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: { now: () => number deps: () => { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } + logger: StructuredAgentSessionLogger onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] statusSink?: StructuredAgentSessionStatusSink } @@ -82,6 +89,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: { sessions: args.sessions, getRecord: (sessionId) => args.deps().store.getRecord(sessionId), now: args.now, + logger: deferredStructuredAgentSessionLogger(() => args.deps().logger), onStatusChanged: (summary, options) => args.deps().onSessionStatusChanged?.(summary, options), // Resolved per call for the same reason the other deps are: the host builds this feed in a // field initializer, before its constructor parameters are assigned. @@ -104,6 +112,10 @@ export class StructuredAgentSessionStatusFeed { constructor(private readonly deps: StructuredAgentSessionStatusFeedDeps) {} + private logFailure(scope: string, message: string, sessionId: string, error: unknown): void { + this.deps.logger.warn(message, { scope, sessionId, error }) + } + /** Opens with every session this host has projected, live ones re-read, then only changes. */ subscribe(subscriber: StructuredAgentSessionStatusSubscriber): () => void { // Re-project before registering: a change found here has to reach the subscribers that @@ -131,7 +143,7 @@ export class StructuredAgentSessionStatusFeed { try { this.ownership.forget(sessionId) } catch (error) { - console.warn('[structured-session-status] status sink forget failed', error) + this.logFailure('status-sink-forget', 'status sink forget failed', sessionId, error) } const previous = this.published.get(sessionId) if (!previous || (!previous.children && !previous.backgroundTasks)) { @@ -215,7 +227,7 @@ export class StructuredAgentSessionStatusFeed { this.deps.onStatusChanged?.(summary, { replay: options?.replay === true }) } catch (error) { // An observer must never cost the subscribers their status event. - console.warn('[structured-session-status] status observer failed', error) + this.logFailure('status-observer', 'status observer failed', sessionId, error) } } @@ -315,7 +327,7 @@ export class StructuredAgentSessionStatusFeed { try { return this.ownership.readChildWork(sessionId) } catch (error) { - console.warn('[structured-session-status] child work read failed', error) + this.logFailure('child-work-read', 'child work read failed', sessionId, error) return undefined } } @@ -328,13 +340,13 @@ export class StructuredAgentSessionStatusFeed { try { this.ownership.publishChildWork(sessionId, evidence, session.params.provider) } catch (error) { - console.warn('[structured-session-status] child work publish failed', error) + this.logFailure('child-work-publish', 'child work publish failed', sessionId, error) return false } try { this.deps.onChildWorkChanged?.(sessionId) } catch (error) { - console.warn('[structured-session-status] child work observer failed', error) + this.logFailure('child-work-observer', 'child work observer failed', sessionId, error) } return true } @@ -347,7 +359,7 @@ export class StructuredAgentSessionStatusFeed { try { this.ownership.publish(summary, location) } catch (error) { - console.warn('[structured-session-status] status sink publish failed', error) + this.logFailure('status-sink-publish', 'status sink publish failed', summary.sessionId, error) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts index fff0ae2383b..aea5e93168a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts @@ -13,6 +13,7 @@ import { StructuredAgentSessionStatusFeed, type StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const SESSION = 'reenter-session' const journals = createTrackedJournalOpener() @@ -62,6 +63,7 @@ async function createFeed() { forget: vi.fn((subject) => server.dropStructuredStatus(subject)) } const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions, getRecord: () => null, now: () => 1_000, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts index dafe54e1790..d47efcd4cad 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts @@ -20,6 +20,8 @@ import { createTrackedJournalOpener } from '../agent-session-journal/journal-hos import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { testEventSinkLogging } from './structured-agent-session-logger-test-support' const SESSION = 'recency-session' const CODEX_THREAD = 'thread-parent' @@ -60,6 +62,7 @@ async function openSession() { // The host's status row and child records, which the feed writes into and reads back. const server = new AgentHookServer() const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([ [ SESSION, @@ -81,7 +84,7 @@ async function openSession() { }) const events: AgentSessionStatusEvent[] = [] feed.subscribe({ id: 'list-1', emit: (event) => events.push(event) }) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) deferred.bind({ journal, fence: 1, publish: () => feed.publish(SESSION, journal) }) const drain = async (): Promise => { expect(await deferred.drained()).toEqual({ ok: true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index cf31d1d47f1..3881e8c429f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -23,6 +23,7 @@ import { import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { MAX_RETAINED_SESSION_ACTIVITIES } from './structured-agent-session-activity-retention' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const SESSION = 'subscriber-session' @@ -227,6 +228,7 @@ describe('AgentSessionSubscribers', () => { stateDirectory: join(root, 'unread-journal') }) const statusFeed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([ [ SESSION, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 871c05a6e33..6ef96b7f98c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -76,6 +76,10 @@ function openHost( probeOwner?: (record: AgentSessionRecord) => Promise ): void { host = new StructuredAgentSessionHost({ + logger: { + warn: (_message, fields) => hostErrors.push(fields.error), + error: (_message, fields) => hostErrors.push(fields.error) + }, store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), @@ -83,7 +87,6 @@ function openHost( mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS }, now: () => clock, - onEventSinkError: ({ error }) => hostErrors.push(error), statusSink, ...(probeOwner ? { probeOwner } : {}) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts index c9cdaa997b1..7e59f695d6d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -16,6 +16,7 @@ import { threadGoalPlan } from './structured-agent-session-thread-goal' import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -68,6 +69,7 @@ function context( flushStreamedEvents: () => Promise = async () => undefined ): AgentSessionTurnContext { return { + logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', journal, fence: 1, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 3e497c311ba..a09024ffcdd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -8,6 +8,7 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -49,6 +50,7 @@ describe('performCancel', () => { ) const cancelTurn = vi.fn(async () => ({ cancelled: true })) const ctx: AgentSessionTurnContext = { + logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', journal, fence: 1, @@ -103,6 +105,7 @@ describe('performCancel', () => { } ) const ctx: AgentSessionTurnContext = { + logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', journal, fence: 1, @@ -148,6 +151,7 @@ describe('performCancel', () => { { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const ctx: AgentSessionTurnContext = { + logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', journal, fence: 1, @@ -182,6 +186,7 @@ describe('performCancel', () => { const cancelTurn = vi.fn(async () => ({ cancelled: true })) const stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) const ctx: AgentSessionTurnContext = { + logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', journal, fence: 1, @@ -220,6 +225,7 @@ describe('performCancel', () => { const cancelTurn = vi.fn(async () => ({ cancelled: true })) const stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) const ctx: AgentSessionTurnContext = { + logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', journal, fence: 1, @@ -299,6 +305,7 @@ describe('what a conversation Stop reports when the provider stopped nothing', ( await openTurn() } const ctx: AgentSessionTurnContext = { + logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', journal, fence: 1, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 13aa9d72aea..6ee38fa1d62 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -41,6 +41,7 @@ import { isJournalWrittenByNewerOrca, journalOpenRefusal } from '../agent-session-journal/journal-open-failure' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' export { performCancel } from './structured-agent-session-turns-cancel' @@ -50,6 +51,7 @@ export type AgentSessionTurnContext = { journal: AgentSessionJournal fence: number adapter: StructuredAgentSessionAdapter + logger: StructuredAgentSessionLogger persistedOptions?: Readonly> persistOptions: (options: Readonly>) => Promise /** Opaque client identity recorded as the resolver of a prompt. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index 2cb38a46634..b2ddb882207 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -13,6 +13,7 @@ import { type StructuredAgentSessionUnexpectedExitContext, type StructuredAgentSessionUnexpectedExitSession } from './structured-agent-session-unexpected-exit' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const exitOutcome = (agent: string): string => `${agent} stopped while this response was in progress. You can continue in this conversation.` @@ -104,7 +105,9 @@ describe('provider-exit settlement', () => { } as unknown as StructuredAgentSessionHostSession await settleUnexpectedStructuredAgentSessionExit( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the exit settlement reads only these members of its context; the store double is partial. { + logger: recordingStructuredAgentSessionLogger().logger, store, sessions: new Map([[SESSION, session]]), flushLifecycle: async () => { @@ -295,6 +298,7 @@ describe('provider-exit settlement', () => { const { store } = mutableStore() const context: StructuredAgentSessionUnexpectedExitContext = { + logger: recordingStructuredAgentSessionLogger().logger, store, sessions: new Map([[SESSION, session]]), flushLifecycle: async () => { @@ -354,6 +358,7 @@ describe('provider-exit settlement', () => { const { store } = mutableStore() const context: StructuredAgentSessionUnexpectedExitContext = { + logger: recordingStructuredAgentSessionLogger().logger, store, sessions: new Map([[SESSION, session]]), flushLifecycle: async () => ({ ok: true }), @@ -406,7 +411,7 @@ describe('provider-exit settlement', () => { }) } } - const release = vi.fn() + const log = recordingStructuredAgentSessionLogger() const publishFence = vi.fn() const event = { type: 'ended' as const, @@ -424,12 +429,12 @@ describe('provider-exit settlement', () => { publishFence, serialize: async (_sessionId, task) => task(), now: () => 1, - onBarrierError: release + logger: log.logger } await settleUnexpectedStructuredAgentSessionExit(context, event) expect(session.child).toBeNull() expect(publishFence).toHaveBeenCalledTimes(1) - expect(release).toHaveBeenCalledTimes(2) + expect(log.scopes()).toEqual(['exit-lifecycle-barrier', 'exit-settlement']) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 3f758f81607..419f7005c40 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -17,6 +17,7 @@ import { unfinishedStructuredAgentSessionWorkWasInterrupted } from './structured-agent-session-dead-generation-settlement' import type { StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' type UnexpectedExitLifecycleEvent = StructuredAgentSessionEndedEvent & { cause: 'unexpected-exit' @@ -37,7 +38,7 @@ export type StructuredAgentSessionUnexpectedExitContext< publishStatus?: (sessionId: string) => void serialize: (sessionId: string, task: () => Promise) => Promise now: () => number - onBarrierError?: (sessionId: string, error: unknown) => void + logger: StructuredAgentSessionLogger } export async function settleUnexpectedStructuredAgentSessionExit< @@ -93,10 +94,10 @@ export async function settleUnexpectedStructuredAgentSessionExit< try { const barrier = await context.flushLifecycle(unexpectedEvent.sessionId) if (!barrier.ok) { - context.onBarrierError?.(unexpectedEvent.sessionId, barrier.error) + logExitFailure(context, unexpectedEvent, 'exit-lifecycle-barrier', barrier.error) } } catch (error) { - context.onBarrierError?.(unexpectedEvent.sessionId, error) + logExitFailure(context, unexpectedEvent, 'exit-lifecycle-barrier', error) } await retryUnexpectedExitSettlement({ context, @@ -136,7 +137,7 @@ export async function settleUnexpectedStructuredAgentSessionExit< exitReason: unexpectedEvent.reason.slice(0, MAX_UNEXPECTED_EXIT_REASON_CHARS) }) } catch (error) { - context.onBarrierError?.(unexpectedEvent.sessionId, error) + logExitFailure(context, unexpectedEvent, 'exit-owner-release', error) } finally { endChild() if (released) { @@ -148,7 +149,7 @@ export async function settleUnexpectedStructuredAgentSessionExit< } async function retryUnexpectedExitSettlement(input: { - context: Pick + context: Pick event: UnexpectedExitLifecycleEvent journal: DeadGenerationJournal fence: number @@ -157,8 +158,8 @@ async function retryUnexpectedExitSettlement(input: { exitedDuringStartup: boolean failureTextContext: AgentSessionFailureWordsContext showUnexpectedExitOutcome?: boolean -}): Promise { - return settleStructuredAgentSessionDeadGeneration({ +}): Promise { + const settled = await settleStructuredAgentSessionDeadGeneration({ journal: input.journal, sessionId: input.event.sessionId, fence: input.fence, @@ -170,8 +171,23 @@ async function retryUnexpectedExitSettlement(input: { failureTextContext: input.failureTextContext, ...(input.exitedDuringStartup ? { exitedDuringStartup: { generation: input.event.acquisitionGeneration } } - : {}), - onError: input.context.onBarrierError + : {}) + }) + if (!settled.ok) { + logExitFailure(input.context, input.event, 'exit-settlement', settled.error) + } +} + +function logExitFailure( + context: Pick, + event: UnexpectedExitLifecycleEvent, + scope: string, + error: unknown +): void { + context.logger.warn('settling a provider exit did not finish', { + scope, + sessionId: event.sessionId, + error }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts index 3d38c1cb38d..20c5abd595e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts @@ -25,6 +25,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ function eventually(assertion: () => unknown): Promise { @@ -84,6 +85,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 2981815c1f1..08532f63c1f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -50,6 +50,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const DEAD_OWNER: AgentSessionProcessIdentity = { @@ -121,6 +122,7 @@ async function seedStore(record: PersistedAgentSessionRecord): Promise { /** Every recorded owner in these fixtures is long gone; that is the present-time evidence. */ function openHost(overrides: Partial = {}): void { host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire, diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index 380869ed21d..8bbec6f5588 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -23,6 +23,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const caller = { callerKey: 'desktop' } let directory: string @@ -64,6 +65,7 @@ let ownerProbe: AgentSessionOwnerProbe = { outcome: 'pid-absent' } async function openHost(): Promise { store = await openTestAgentSessionRecordStore(generationRoot()) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter, journalDatabase: openTestJournalHostDatabase(generationRoot()), diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 30a4bfecf51..1df9b08c78f 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -107,6 +107,7 @@ export function runStructuredConversationCommand( return admitAndRunAgentSessionMutation({ store, adapter: context.deps.adapter, + logger: context.deps.logger, callerKey: caller.callerKey, envelope, // Starts the agent only to settle a rewind in doubt, as a send does; a /clear itself starts nothing. diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts index b572e51e5f3..a45b821e465 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts @@ -13,6 +13,9 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds' import { rewindRefusal } from './structured-rewind-refusal' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' + +type RewindRecoveryDeps = { store: AgentSessionRecordStore; logger: StructuredAgentSessionLogger } export function persistRewindRecord( store: AgentSessionRecordStore, @@ -33,7 +36,7 @@ export function persistRewindRecord( * settled refused rather than proven. Bookkeeping only: the chat is already attached either way. */ async function settleUnsupportedClaudeRewind( - store: AgentSessionRecordStore, + { store, logger }: RewindRecoveryDeps, sessionId: string, fence: number, rewind: AgentSessionRewindRecord @@ -52,7 +55,8 @@ async function settleUnsupportedClaudeRewind( outcome: { status: 'failed', code: refusal.code, rewindReason: 'unsupported' } }) } catch (error) { - console.warn('[structured-rewind] pending Claude rewind was not settled:', { + logger.warn('a pending Claude rewind was not settled', { + scope: 'rewind-unsupported-settlement', sessionId, operationId: rewind.operationId, error @@ -62,20 +66,21 @@ async function settleUnsupportedClaudeRewind( /** Recovery observes provider state; it never repeats an ambiguous native mutation. */ export async function recoverStructuredRewind( - store: AgentSessionRecordStore, + deps: RewindRecoveryDeps, sessionId: string, journal: AgentSessionJournal, fence: number, adapter?: StructuredAgentSessionAdapter, now: () => number = Date.now ): Promise { + const { store } = deps let rewind = store.getRecord(sessionId)?.rewind if (rewind?.phase !== 'provider-succeeded' && rewind?.phase !== 'prepared') { return } const target = parseAgentJournalItemKey(rewind.providerItemId ?? rewind.itemId) if (target?.provider === 'claude') { - await settleUnsupportedClaudeRewind(store, sessionId, fence, rewind) + await settleUnsupportedClaudeRewind(deps, sessionId, fence, rewind) return } if (target?.provider === 'codex' && !rewind.hydrationVerified) { diff --git a/src/main/observability/index.ts b/src/main/observability/index.ts index 74728a2d81b..f620fb9a77d 100644 --- a/src/main/observability/index.ts +++ b/src/main/observability/index.ts @@ -139,13 +139,19 @@ let consent: ObservabilityConsent | null = null /** Create the local file sink, install it as the active tracer sink, and * update module-level `sink`. */ -function installLocalSink(): void { - const localSink = createLocalFileSink({ filePath: getTraceFilePath() }) - sink = localSink - setActiveSink(localSink) +function installLocalSink(filePath: string): void { + try { + const localSink = createLocalFileSink({ filePath }) + sink = localSink + setActiveSink(localSink) + } catch (error) { + // Diagnostics are optional: a logs folder that cannot be opened must not stop the app or host. + console.warn(`[observability] tracing is off: cannot open ${filePath}`, error) + } } -export function initObservability(): ObservabilityConsent { +/** `traceFilePath` names another process's own file; the desktop's is the bundle's default. */ +export function initObservability(options?: { traceFilePath?: string }): ObservabilityConsent { const c = resolveObservabilityConsent() consent = c if (!c.localFileEnabled) { @@ -153,7 +159,7 @@ export function initObservability(): ObservabilityConsent { // tracer's active sink unset, so all spans are no-ops. return c } - installLocalSink() + installLocalSink(options?.traceFilePath ?? getTraceFilePath()) installSecurePathHardeningReporter() return c } @@ -183,6 +189,15 @@ function installSecurePathHardeningReporter(): void { }) } +/** Writes buffered lines now, for an exit that runs no shutdown. */ +export function flushObservability(): void { + try { + sink?.flush() + } catch { + // Nothing is left to report to. + } +} + export async function shutdownObservability(): Promise { setSecurePathHardeningReporter(null) // Order matters: tracer first so no new pushes arrive while the local sink diff --git a/src/main/observability/logs-directory.ts b/src/main/observability/logs-directory.ts index 20ad104af74..7b244bec43e 100644 --- a/src/main/observability/logs-directory.ts +++ b/src/main/observability/logs-directory.ts @@ -35,6 +35,11 @@ export function getTraceFilePath(): string { return join(getLogsDirectory(), 'main.trace.ndjson') } +/** orcad's own trace file, named for its process as the daemon's log is. */ +export function getOrcadTraceFilePath(): string { + return join(getLogsDirectory(), 'orcad.trace.ndjson') +} + /** NDJSON lifecycle log written by the detached daemon process. Shared here so * the daemon fork (which passes it as `--log-file`) and the bundle collector * (which reads it) agree on one path. */ diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 6c0e9bbad22..854b4dfb723 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -30,6 +30,7 @@ import { export { parseArgs } let runOrcadQuitHandlers = (): void => {} +let closeOrcadObservability = (): void => {} function createNodeAppEnvironment(): AppEnvironment { const quitHandlers: (() => void)[] = [] @@ -109,7 +110,12 @@ export async function startOrcad(options: OrcadOptions = {}): Promise startOrcadRuntime(options, registerCleanup), - () => runOrcadQuitHandlers() + () => { + runOrcadQuitHandlers() + // Last, after every quit handler, so the spans they end still reach the file. + closeOrcadObservability() + closeOrcadObservability = () => {} + } ) } @@ -122,6 +128,8 @@ async function startOrcadRuntime( const { registerHeadlessPtyRuntime, getLocalPtyProvider, getSshPtyProvider } = await import('../ipc/pty') const { getAppEnvironment } = await import('../../shared/app-environment') + const { installOrcadObservability } = await import('./orcad-observability') + closeOrcadObservability = installOrcadObservability() const { resolveAdvertisedPairingEndpoint } = await import('../runtime/pairing-endpoint') const { ServeReadinessPublisher } = await import('../server/serve-readiness') const { createOrcadProfileStateStartup } = await import('./orcad-profile-state-startup') diff --git a/src/main/orcad/orcad-observability.test.ts b/src/main/orcad/orcad-observability.test.ts new file mode 100644 index 00000000000..03b49342523 --- /dev/null +++ b/src/main/orcad/orcad-observability.test.ts @@ -0,0 +1,122 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { installOrcadObservability } from './orcad-observability' + +const CI_ENV = [ + 'CI', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'CIRCLECI', + 'TRAVIS', + 'BUILDKITE', + 'JENKINS_URL', + 'TEAMCITY_VERSION' +] + +describe('orcad trace file', () => { + let dataRoot: string + + beforeEach(() => { + dataRoot = mkdtempSync(join(tmpdir(), 'orca-orcad-observability-')) + setAppEnvironment({ + getPath: (name) => { + if (name !== 'userData') { + throw new Error(`unexpected getPath: ${name}`) + } + return dataRoot + }, + getAppPath: () => dataRoot, + getVersion: () => '0.0.0-test', + isPackaged: () => true, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + // The lane is off in CI and when diagnostics are disabled; this asserts the default host. + for (const name of [...CI_ENV, 'ORCA_DIAGNOSTICS_DISABLED']) { + vi.stubEnv(name, '') + } + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + }) + + afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + rmSync(dataRoot, { recursive: true, force: true }) + }) + + const traceRecords = (): { name: string; attributes: Record }[] => + readFileSync(join(dataRoot, 'logs', 'orcad.trace.ndjson'), 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + + const reportLateSettlement = (): void => + createStructuredAgentSessionLogger().warn('settling a late dispatch failed', { + scope: 'late-settlement', + sessionId: 'session-1', + error: new Error('disk full') + }) + + it('writes a structured chat failure to its own file under the data root, flushed by close', () => { + const close = installOrcadObservability() + + reportLateSettlement() + close() + + expect(traceRecords()).toContainEqual( + expect.objectContaining({ + name: 'agentSession.late-settlement', + attributes: expect.objectContaining({ sessionId: 'session-1' }), + exit: expect.objectContaining({ _tag: 'Failure' }) + }) + ) + expect(existsSync(join(dataRoot, 'logs', 'main.trace.ndjson'))).toBe(false) + }) + + it('flushes what is buffered when the process exits with no cleanup', () => { + const before = new Set(process.listeners('exit')) + const close = installOrcadObservability() + const onExit = process.listeners('exit').filter((listener) => !before.has(listener)) + expect(onExit).toHaveLength(1) + + reportLateSettlement() + onExit[0]?.call(process, 1) + + expect(traceRecords().map((record) => record.name)).toContain('agentSession.late-settlement') + close() + expect(process.listeners('exit')).toHaveLength(before.size) + }) + + it('still starts when the logs folder cannot be opened, with tracing off', () => { + // A regular file where the folder belongs fails the open as a read-only mount or EACCES does. + writeFileSync(join(dataRoot, 'logs'), 'not a folder') + const warn = vi.mocked(console.warn) + + const close = installOrcadObservability() + reportLateSettlement() + close() + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('[observability] tracing is off'), + expect.any(Error) + ) + expect(readFileSync(join(dataRoot, 'logs'), 'utf8')).toBe('not a folder') + }) +}) + +// Booting orcad here would need its whole runtime; the wiring is pinned the way the agent-status +// store's is, by the entry point's own text. +it('orcad installs the trace file before its runtime and closes it after every quit handler', () => { + const entry = readFileSync(join(import.meta.dirname, 'orcad-entry.ts'), 'utf8') + const install = entry.indexOf('closeOrcadObservability = installOrcadObservability()') + expect(install).toBeGreaterThan(-1) + expect(install).toBeLessThan(entry.indexOf('new OrcaRuntimeService(')) + const quit = entry.indexOf(' runOrcadQuitHandlers()\n') + expect(quit).toBeGreaterThan(-1) + expect(entry.indexOf(' closeOrcadObservability()\n', quit)).toBeGreaterThan(quit) +}) diff --git a/src/main/orcad/orcad-observability.ts b/src/main/orcad/orcad-observability.ts new file mode 100644 index 00000000000..4200180a2c9 --- /dev/null +++ b/src/main/orcad/orcad-observability.ts @@ -0,0 +1,19 @@ +// orcad's local trace file: the desktop's sink and consent gates under orcad's own data root and +// its own name (`/logs/orcad.trace.ndjson`), so a headless host's spans, its reported +// structured-chat failures among them, reach a file and not only the supervisor's stderr. + +import process from 'node:process' +import { flushObservability, initObservability, shutdownObservability } from '../observability' +import { getOrcadTraceFilePath } from '../observability/logs-directory' + +/** Installed before the runtime, so its first span lands. Returns the close, which the host runs + * last in its cleanup so every quit handler's spans land too. */ +export function installOrcadObservability(): () => void { + initObservability({ traceFilePath: getOrcadTraceFilePath() }) + // `exit` runs synchronously on process.exit and after an uncaught exception; no cleanup does. + process.once('exit', flushObservability) + return () => { + process.off('exit', flushObservability) + void shutdownObservability() + } +} diff --git a/src/main/runtime/agent-session-legacy-record-import.test.ts b/src/main/runtime/agent-session-legacy-record-import.test.ts index da09cb2257d..bf2156d0710 100644 --- a/src/main/runtime/agent-session-legacy-record-import.test.ts +++ b/src/main/runtime/agent-session-legacy-record-import.test.ts @@ -17,10 +17,10 @@ import { } from '../../shared/agent-session-record.test-fixture' import { journalPragmaNumber } from '../native-chat/agent-session-journal/journal-database' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' -import type { LegacyAgentSessionRecordImportReport } from './agent-session-legacy-record-import' import { AgentSessionRecordStore } from './agent-session-record-store' import { legacyAgentSessionStorePath } from './agent-session-record-store-file' import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const NOW = 1_800_000_000_000 const ALPHA = 'session-alpha' @@ -96,15 +96,19 @@ async function writeLegacy(file: unknown, backup?: unknown): Promise { async function install(): Promise<{ database: JournalHostDatabase store: AgentSessionRecordStore - reports: LegacyAgentSessionRecordImportReport[] + reports: unknown[] }> { - const reports: LegacyAgentSessionRecordImportReport[] = [] + const log = recordingStructuredAgentSessionLogger() const database = await openStructuredAgentSessionJournalDatabase({ + logger: log.logger, stateDirectory: root, - hostId: 'local', - onLegacyRecordImportReport: (report) => reports.push(report) + hostId: 'local' }) opened.push(database) + // Each report reaches the log as one entry under its scope, its kind as the outcome. + const reports = log.entries + .filter((entry) => entry.fields.scope === 'legacy-record-import') + .map(({ fields: { scope: _scope, outcome, ...rest } }) => ({ kind: outcome, ...rest })) return { database, store: AgentSessionRecordStore.open({ journalDatabase: database, hostId: 'local' }), diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 47199f6199f..a12fc06c479 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -27,6 +27,7 @@ import { stopStructuredAgentSessionRuntime, waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const SESSION = 'claude-integration-1' const PROVIDER_SESSION = claudeSessionIdForOrcaSession(SESSION) @@ -234,6 +235,7 @@ beforeEach(async () => { publishStructuredAgentSessionTab: vi.fn(), ensureStructuredAgentSessionHost: () => ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', diff --git a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts index 3b3510916ba..eae4b7b7e32 100644 --- a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts @@ -27,7 +27,11 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi // The restore index is bookkeeping: one that cannot be written (a newer Orca's records, a // failing disk) is reported, and the tab still closes. await host.setSessionTabVisibility(tab.sessionId, false).catch((error: unknown) => { - console.warn('[structured-agent-session] recording a closed chat tab failed', error) + host.deps.logger.warn('recording a closed chat tab failed', { + scope: 'tab-visibility-close', + sessionId: tab.sessionId, + error + }) }) } } diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index adcd47baad3..b25ce381def 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -14,6 +14,7 @@ import { compareWorktreePs } from './runtime-worktree-status-projection' import type { Repo } from '../../shared/repo-types' import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment' import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { maybeAutoRenameWorkspaceOnFirstStructuredTurn } from '../agent-hooks/first-work-structured-session-rename' import { firstWorkRenameDeps } from '../agent-hooks/first-work-rename-runtime' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' @@ -142,6 +143,8 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis stateDirectory: getProfileUserDataPath(), hostId: LOCAL_EXECUTION_HOST_ID, claimKeyId: this.agentSessionClaimSigner.keyId, + // The host's local trace file (the desktop's or orcad's own), plus the console. + logger: createStructuredAgentSessionLogger(), // Resolves folder workspaces as well as git worktrees, so a chat session // in a plain folder lands in the folder rather than failing to resolve. resolveWorkspacePath: async (workspaceId) => diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index f8845221e66..61c2e0b3605 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -125,7 +125,11 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu await host .setSessionTabVisibility(input.sessionId, true, ...(input.tabId ? [input.tabId] : [])) .catch((error: unknown) => { - console.warn('[structured-agent-session] recording an opened chat tab failed', error) + host.deps.logger.warn('recording an opened chat tab failed', { + scope: 'tab-visibility-open', + sessionId: input.sessionId, + error + }) }) } this.projectStructuredAgentSessionTab(input) diff --git a/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts b/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts index b931af57463..9936c954b5b 100644 --- a/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts +++ b/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts @@ -1,6 +1,13 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' -const installed = vi.hoisted(() => ({ deps: null as Record | null })) +const installed = vi.hoisted(() => { + const state: { + deps: Record | null + logger: StructuredAgentSessionLogger | null + } = { deps: null, logger: null } + return state +}) vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -10,14 +17,18 @@ vi.mock('electron', () => ({ })) vi.mock('./structured-agent-session-runtime', () => ({ - ensureStructuredAgentSessionHost: vi.fn(async (deps: Record) => { - installed.deps = deps - }) + ensureStructuredAgentSessionHost: vi.fn( + async (deps: Record & { logger: StructuredAgentSessionLogger }) => { + installed.deps = deps + installed.logger = deps.logger + } + ) })) import { readFileSync } from 'node:fs' import { join } from 'node:path' import { OrcaRuntimeService } from './orca-runtime' +import { _resetTracerForTests, setActiveSink } from '../observability/tracer' import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' type OrcaRuntimeDeps = NonNullable[2]> @@ -78,6 +89,11 @@ describe('every host that constructs a runtime wires the agent-status store', () }) describe('structured status sink wiring', () => { + afterEach(() => { + _resetTracerForTests() + vi.restoreAllMocks() + }) + it('hands the host the sink the runtime was constructed with', async () => { installed.deps = null const sink: StructuredAgentSessionStatusSink = { publish: vi.fn(), forget: vi.fn() } @@ -88,6 +104,32 @@ describe('structured status sink wiring', () => { expect(installed.deps?.['statusSink']).toBe(sink) }) + // The runtime class does not typecheck its own calls, so the required logger is pinned here: + // a logger that writes nowhere would pass every host test while the desktop dropped failures. + it('hands the host the trace-file logger', async () => { + installed.logger = null + const push = vi.fn() + setActiveSink({ push, flush: () => {}, close: () => {} }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const runtime = new OrcaRuntimeService() + + await runtime.ensureStructuredAgentSessionHost() + const installedLogger = (): StructuredAgentSessionLogger | null => installed.logger + installedLogger()?.warn('renewing a chat lease failed', { + scope: 'lease-renewal', + sessionId: 'session-1', + error: new Error('database is locked') + }) + + expect(push).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'agentSession.lease-renewal', + attributes: expect.objectContaining({ sessionId: 'session-1' }), + exit: expect.objectContaining({ _tag: 'Failure' }) + }) + ) + }) + it('installs without a sink when none was provided', async () => { installed.deps = null const runtime = new OrcaRuntimeService() diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts index 4d919df556e..1b591051936 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts @@ -11,15 +11,21 @@ import { commitStructuredAgentSessionLaunchPrompt } from './agent-launch-structu import { structuredAgentSessionPayloadFingerprint } from '../../../../shared/structured-agent-session-mutation' import { structuredAgentSessionSendBody } from '../../../../shared/structured-agent-session-outbox' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { recordingStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const CALLER = { callerKey: 'trusted-local:runtime' } function hostWith( send: ReturnType, - journalSnapshot: ReturnType = vi.fn(() => ({ submissions: [] })) + journalSnapshot: ReturnType = vi.fn(() => ({ submissions: [] })), + log = recordingStructuredAgentSessionLogger() ): StructuredAgentSessionHost { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements the only method this module reaches; any other would throw rather than read a wrong value. - return { send, journalSnapshot } as unknown as StructuredAgentSessionHost + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements the only members this module reaches; any other would throw rather than read a wrong value. + return { + send, + journalSnapshot, + deps: { logger: log.logger } + } as unknown as StructuredAgentSessionHost } function commit(host: StructuredAgentSessionHost | null, text = 'do the thing') { @@ -80,11 +86,15 @@ describe('committing a launch prompt', () => { }) it('claims nothing, and does not fail the launch, when no row was committed', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) + const log = recordingStructuredAgentSessionLogger() + const failure = new Error('host gone') const send = vi.fn(async () => { - throw new Error('host gone') + throw failure }) - await expect(commit(hostWith(send))).resolves.toBeNull() + await expect(commit(hostWith(send, undefined, log))).resolves.toBeNull() + expect(log.entries.map((entry) => entry.fields)).toEqual([ + { scope: 'launch-prompt', sessionId: 'sess-1', error: failure } + ]) }) it('sends nothing when there is no host or no text', async () => { diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts index 11762137ba2..00c779fffe0 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts @@ -72,7 +72,11 @@ export async function commitStructuredAgentSessionLaunchPrompt(args: { } catch { // The host may have gone away before the snapshot; the caller retains the text in that case. } - console.warn('[agent-launch] the session was created, its launch prompt was not sent', error) + args.host.deps.logger.warn("sending a created chat's launch prompt failed", { + scope: 'launch-prompt', + sessionId: args.sessionId, + error + }) return null } } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 64c7ec1c8b1..6d1ddf9f359 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -13,6 +13,7 @@ import type { RpcRequest, RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' const SESSION = 'session-adoption-replay' const THREAD = 'thread-adoption-replay' @@ -184,6 +185,7 @@ describe('committed adopting create RPC replay', () => { const store = await openTestAgentSessionRecordStore(root) const sessionAdapter = adapter() host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index 9cbad804cd5..b846be96dd2 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -287,8 +287,11 @@ describe('the accessor', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const logged = (): unknown[] => warn.mock.calls - .filter(([line]) => line === '[agent-session] opening the conversation for a read failed:') - .map(([, error]) => error) + .filter( + ([line]) => + line === '[agent-session] open-for-read: opening the conversation for a read failed' + ) + .map(([, fields]) => fields?.error) const reconnect = async (): Promise => [ ...(await call('agentSession.subscribe', { sessionId: SESSION })), ...(await call('agentSession.history', { sessionId: SESSION, direction: 'tail' })) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts new file mode 100644 index 00000000000..cf708f37491 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts @@ -0,0 +1,41 @@ +import { expect, it, vi } from 'vitest' +import type { AgentSessionAttachParams } from '../../../native-chat/agent-session-wire/structured-agent-session-attach' +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { recordingStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { commitStructuredAgentSessionCreate } from './structured-agent-session-create' + +// A chat created whose tab could not be published is answered as unconfirmed; the cause is logged. +it('logs a created chat whose tab could not be published, and answers it unconfirmed', async () => { + const log = recordingStructuredAgentSessionLogger() + const failure = new Error('tab write failed') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements the only members the commit reaches past attach. + const host = { + attach: vi.fn(async () => ({ ok: true, value: { sessionId: 'session-1' } })), + getSessionTabId: vi.fn(() => null), + deps: { logger: log.logger } + } as unknown as StructuredAgentSessionHost + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the commit reaches only the tab publication on the runtime. + const runtime = { + publishStructuredAgentSessionTab: vi.fn(async () => { + throw failure + }) + } as unknown as OrcaRuntimeService + + const result = await commitStructuredAgentSessionCreate({ + runtime, + caller: { callerKey: 'client-1' }, + prepared: { + host, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stubbed attach reads none of it. + attachParams: {} as AgentSessionAttachParams, + tab: { workspaceId: 'workspace-1', agent: 'codex' } + }, + activate: true + }) + + expect(result).toMatchObject({ ok: false, refusal: { details: { reason: 'tabUnconfirmed' } } }) + expect(log.entries.map((entry) => entry.fields)).toEqual([ + { scope: 'create-tab-publication', sessionId: 'session-1', error: failure } + ]) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index 0c3b7b08713..1d0899a014d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -142,7 +142,11 @@ export async function commitStructuredAgentSessionCreate(args: { ...(surfaceTabId ? { tabId: surfaceTabId } : {}) }) } catch (error) { - console.warn('[agent-session] create committed before tab publication failed', error) + prepared.host.deps.logger.warn('publishing the tab of a created chat failed', { + scope: 'create-tab-publication', + sessionId: result.value.sessionId, + error + }) return { ok: false, refusal: refuse( diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 45c01c2c63a..446fb107411 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -28,6 +28,7 @@ import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' const CONNECTION = 'connection-1' const CLIENT = { @@ -78,6 +79,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts index f34ecb5d6cd..17aadf32a80 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { recordingStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import { isDefinitiveAgentSessionCreateRefusal } from '../../../../shared/agent-session-definitive-refusal' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' @@ -49,7 +50,11 @@ function hostStub(): StructuredAgentSessionHost { cursor: { epoch: 'epoch-a', sequence: 0 }, value: { sessionId: SESSION, fence: 1, page: {}, unconfirmedClientMessageIds: [] } })) - return { attach } as unknown as StructuredAgentSessionHost + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create reaches only attach, and the logger a failure past it reports to. + return { + attach, + deps: { logger: recordingStructuredAgentSessionLogger().logger } + } as unknown as StructuredAgentSessionHost } const resolvedIntent = { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 85d3e2a5b4b..57606c8c456 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -19,6 +19,8 @@ import { import type { RpcRequest, RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' +import { recordingStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger-test-support' export const SESSION = 'session-alpha' export const FINGERPRINT = 'f'.repeat(64) @@ -94,6 +96,7 @@ export const STATUS_ITEMS: AgentJournalRenderItem[] = [ /** One indexed session over a journal that reads back fixed items; the projection is real. */ function statusFeed(): StructuredAgentSessionStatusFeed { return new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([ [ STATUS_SESSION, @@ -206,6 +209,8 @@ export function hostStub(): StructuredAgentSessionHost { ), unsubscribe: vi.fn() }) + // Not a call: the logger the host hands a runtime caller that reports for it. + Reflect.set(hostCalls, 'deps', { logger: recordingStructuredAgentSessionLogger().logger }) return hostCalls as unknown as StructuredAgentSessionHost } diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index f437e806899..c97672269eb 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -38,6 +38,7 @@ import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { commitStructuredAgentSessionCreate } from './structured-agent-session-create' import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' const WORKTREE = `id:${HOST_TEST_LOCATION.workspaceId}` const SOURCE_TAB = `structured-agent-session-${HOST_TEST_SESSION}` @@ -93,6 +94,7 @@ function providerAdapter(): StructuredAgentSessionAdapter { async function openHost(): Promise { store = await openTestAgentSessionRecordStore(directory) host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: providerAdapter(), journalDatabase: openTestJournalHostDatabase(directory), @@ -540,8 +542,8 @@ describe('a chat tab over records a newer Orca wrote', () => { expect((await snapshot()).activeTabId).toBe(`agent-session:${HOST_TEST_SESSION}`) expect(warn).toHaveBeenCalledWith( - '[structured-agent-session] recording an opened chat tab failed', - expect.anything() + '[agent-session] tab-visibility-open: recording an opened chat tab failed', + expect.objectContaining({ scope: 'tab-visibility-open', sessionId: HOST_TEST_SESSION }) ) }) }) @@ -559,8 +561,8 @@ describe('a chat tab whose restore index cannot be written', () => { expect(created.ok ? created.value.tabId : null).toBeUndefined() expect((await snapshot()).activeTabId).toBe(`agent-session:${HOST_TEST_SESSION}`) expect(warn).toHaveBeenCalledWith( - '[structured-agent-session] recording an opened chat tab failed', - failure + '[agent-session] tab-visibility-open: recording an opened chat tab failed', + { scope: 'tab-visibility-open', sessionId: HOST_TEST_SESSION, error: failure } ) expect(store.getSessionTabId(HOST_TEST_SESSION)).toBeNull() }) diff --git a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts index 0d9180dc3e5..25379632875 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts @@ -20,6 +20,7 @@ import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-w import { indexedStatusFeedSession } from '../native-chat/agent-session-wire/structured-agent-session-status-feed-test-session' import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ @@ -76,6 +77,7 @@ async function openJournal(): Promise { function publishedSummary(journal: AgentSessionJournal): AgentSessionStatusSummary { const session = indexedStatusFeedSession({ journal }) const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([[SESSION, session]]), getRecord: () => agentSessionRecordFixture(), now: () => 1_000 diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts index 50e974f1eb5..5afd2d2e770 100644 --- a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -11,6 +11,7 @@ import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' import { AgentHookServer, _internals } from '../agent-hooks/server' import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ @@ -106,6 +107,7 @@ async function awaitingApproval() { const store = new AgentHookServer() const published: AgentSessionStatusSummary[] = [] const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions, getRecord: () => null, now: () => Date.now(), diff --git a/src/main/runtime/structured-agent-session-close.test.ts b/src/main/runtime/structured-agent-session-close.test.ts index 948bb01cce2..500a6cd1beb 100644 --- a/src/main/runtime/structured-agent-session-close.test.ts +++ b/src/main/runtime/structured-agent-session-close.test.ts @@ -9,6 +9,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const hostRef: { current: unknown } = { current: null } @@ -85,8 +86,10 @@ function installHost(options: HostOptions = {}) { throw new Error('the event sink could not be flushed') } }) + const log = recordingStructuredAgentSessionLogger() hostRef.current = { deps: { + logger: log.logger, store: { getRecord: (id: string) => (id === SESSION ? entry : null), getSessionTabId: (id: string) => { @@ -101,7 +104,7 @@ function installHost(options: HostOptions = {}) { setSessionTabVisibility, close } - return { close, setSessionTabVisibility, visible } + return { close, setSessionTabVisibility, visible, log } } describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { @@ -207,11 +210,11 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { }) it('keeps the original failure when the restore itself throws', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = installHost({ stuck: true }) + const restoreFailure = new Error('agent_session_identity_required') host.setSessionTabVisibility.mockImplementation(async (_sessionId, isVisible) => { if (isVisible) { - throw new Error('agent_session_identity_required') + throw restoreFailure } }) @@ -220,7 +223,9 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { expect(outcome.stopped).toBe(false) expect(outcome.closeAttempted).toBe(true) expect(outcome.reason).not.toContain('agent_session_identity_required') - expect(warn).toHaveBeenCalled() + expect(host.log.entries.map((entry) => entry.fields)).toEqual([ + { scope: 'close-tab-restore', sessionId: SESSION, error: restoreFailure } + ]) }) it('claims nothing when the visible-tab index cannot be read', async () => { diff --git a/src/main/runtime/structured-agent-session-close.ts b/src/main/runtime/structured-agent-session-close.ts index 3ee8f265c4d..16bf8cce71d 100644 --- a/src/main/runtime/structured-agent-session-close.ts +++ b/src/main/runtime/structured-agent-session-close.ts @@ -147,9 +147,10 @@ async function restorePersistedTabVisibility( try { await host.setSessionTabVisibility?.(sessionId, true, tabId) } catch (error) { - console.warn( - `[structured-session-close] could not restore the chat tab for ${sessionId} after a failed close`, + host.deps.logger.warn('restoring the chat tab after a failed close failed', { + scope: 'close-tab-restore', + sessionId, error - ) + }) } } diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index facd5e15b05..6545ffb2ecf 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -34,6 +34,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' // The turns a send or Stop is waiting on to open, so a test knows the wait began. const openWaits = vi.hoisted(() => { @@ -217,6 +218,7 @@ beforeEach(async () => { return connection } host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', diff --git a/src/main/runtime/structured-agent-session-dispatch-followups.ts b/src/main/runtime/structured-agent-session-dispatch-followups.ts new file mode 100644 index 00000000000..dbc5a31b0d1 --- /dev/null +++ b/src/main/runtime/structured-agent-session-dispatch-followups.ts @@ -0,0 +1,52 @@ +// What the runtime does when a provider settles a dispatch after the fact: a late answer to a send, +// or the provider going idle with sends it never answered. Both run off the request that caused +// them, so their failure has nobody to return to and is logged instead. + +import { DISPATCH_DOUBT_PROVIDER_IDLE } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' + +type DispatchFollowUpHost = Pick< + StructuredAgentSessionHost, + 'settleLateDispatch' | 'releaseUnansweredDispatches' +> + +export function createStructuredAgentSessionDispatchFollowUps(input: { + /** Null until the host is built: the adapters that call these are built first. */ + host: () => DispatchFollowUpHost | null + logger: StructuredAgentSessionLogger +}): { + onDispatchSettledLate: ( + settlement: Parameters[0] + ) => void + releaseUnansweredDispatches: (input: { sessionId: string }) => void +} { + const { logger } = input + return { + onDispatchSettledLate: (settlement) => { + void input + .host() + ?.settleLateDispatch(settlement) + .catch((error: unknown) => + logger.warn('settling a dispatch the provider answered late failed', { + scope: 'late-settlement', + sessionId: settlement.sessionId, + error + }) + ) + }, + // The provider going idle is what re-derives a doubted send; a late echo still accepts it. + releaseUnansweredDispatches: ({ sessionId }) => { + void input + .host() + ?.releaseUnansweredDispatches({ sessionId, reason: DISPATCH_DOUBT_PROVIDER_IDLE }) + .catch((error: unknown) => + logger.warn('releasing sends the idle provider never answered failed', { + scope: 'unanswered-dispatch', + sessionId, + error + }) + ) + } + } +} diff --git a/src/main/runtime/structured-agent-session-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts new file mode 100644 index 00000000000..9a8d9edb96f --- /dev/null +++ b/src/main/runtime/structured-agent-session-failure-logging.test.ts @@ -0,0 +1,340 @@ +// Failures the desktop host used to drop: it installed the runtime with no error callback, so +// each of these reached nobody. Every one now lands in the required logger under its own scope. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' +import type { StructuredAgentSessionHostDeps } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { StructuredAgentSessionHostRuntimeState } from '../native-chat/agent-session-wire/structured-agent-session-host-runtime-state' +import { + StructuredAgentSessionDeliveryLoop, + type StructuredAgentSessionDeliveryLoopDeps +} from '../native-chat/agent-session-wire/structured-agent-session-delivery-loop' +import { + StructuredAgentSessionIdleSweep, + type StructuredAgentSessionIdleSweepDeps +} from '../native-chat/agent-session-wire/structured-agent-session-idle-sweep' +import { + StructuredAgentSessionQueuedMessageDrain, + type QueuedMessageDrainDeps +} from '../native-chat/agent-session-wire/structured-agent-session-queued-messages' +import { + createStructuredAgentSessionLogger, + neverThrowingStructuredAgentSessionLogger +} from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { _resetTracerForTests, setActiveSink } from '../observability/tracer' +import { createStructuredAgentSessionDispatchFollowUps } from './structured-agent-session-dispatch-followups' +import { createStructuredAgentSessionLifecycleDelivery } from './structured-agent-session-lifecycle-delivery' +import { + STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED, + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const SESSION = 'session-1' + +function rejectingHost(error: Error) { + return { + settleLateDispatch: vi.fn(async () => { + throw error + }), + releaseUnansweredDispatches: vi.fn(async () => { + throw error + }) + } +} + +describe('failures the desktop host used to drop', () => { + it('logs a late dispatch settlement that fails', async () => { + const log = recordingStructuredAgentSessionLogger() + const error = new Error('settlement write failed') + const followUps = createStructuredAgentSessionDispatchFollowUps({ + host: () => rejectingHost(error), + logger: log.logger + }) + + followUps.onDispatchSettledLate({ + sessionId: SESSION, + clientMessageId: 'client-1', + state: 'unknown', + reason: 'provider went idle' + }) + + await vi.waitFor(() => + expect(log.entries).toEqual([ + { + level: 'warn', + message: 'settling a dispatch the provider answered late failed', + fields: { scope: 'late-settlement', sessionId: SESSION, error } + } + ]) + ) + }) + + it('logs releasing the sends an idle provider never answered when that fails', async () => { + const log = recordingStructuredAgentSessionLogger() + const error = new Error('release write failed') + const followUps = createStructuredAgentSessionDispatchFollowUps({ + host: () => rejectingHost(error), + logger: log.logger + }) + + followUps.releaseUnansweredDispatches({ sessionId: SESSION }) + + await vi.waitFor(() => + expect(log.entries).toEqual([ + expect.objectContaining({ + fields: { scope: 'unanswered-dispatch', sessionId: SESSION, error } + }) + ]) + ) + }) + + it('logs a provider start or exit the host could not take', async () => { + const log = recordingStructuredAgentSessionLogger() + const error = new Error('host refused the event') + const lifecycle = createStructuredAgentSessionLifecycleDelivery({ + handle: async () => { + throw error + }, + logger: log.logger, + drainObservedExits: async () => {} + }) + + lifecycle.deliver({ + type: 'started', + sessionId: SESSION, + fence: 1, + acquisitionGeneration: 'generation-1', + reportedOptions: { model: 'gpt-5' }, + restoreSkippedOptions: [] + }) + lifecycle.deliver({ + type: 'ended', + sessionId: SESSION, + reason: 'exited', + cause: 'unexpected-exit', + fence: 1, + acquisitionGeneration: 'generation-1' + }) + await lifecycle.drain() + + expect(log.entries.map((entry) => entry.fields)).toEqual([ + { scope: 'lifecycle-started', sessionId: SESSION, error }, + { scope: 'lifecycle-exit', sessionId: SESSION, error } + ]) + }) + + it('logs provider events the chat journal would not take', async () => { + const log = recordingStructuredAgentSessionLogger() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: minting and binding a sink reads no other host dependency. + const runtime = new StructuredAgentSessionHostRuntimeState({ + logger: log.logger + } as unknown as StructuredAgentSessionHostDeps) + const sink = runtime.eventSinkFor(SESSION) + const error = new Error('journal append failed') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sink calls only appendItem before it fails. + const journal = { + appendItem: async () => { + throw error + } + } as unknown as AgentSessionJournal + sink.bind({ journal, fence: 1, publish: () => {} }) + + sink.sink.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hi' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await expect(sink.drained()).resolves.toMatchObject({ ok: false }) + + expect(log.entries).toEqual([ + expect.objectContaining({ + level: 'error', + fields: { scope: 'journal-event-sink', sessionId: SESSION, error } + }) + ]) + }) +}) + +describe('host collaborators log with their own scope', () => { + const error = new Error('serialize refused') + const rejectingSerialize = async (): Promise => { + throw error + } + + it('the delivery loop logs a failed delivery, and a failed recording of it', async () => { + const log = recordingStructuredAgentSessionLogger() + const loop = new StructuredAgentSessionDeliveryLoop( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a step that rejects at its first serialize reads only these members. + { + serialize: rejectingSerialize, + trackStart: (start: Promise) => start, + logger: log.logger + } as unknown as StructuredAgentSessionDeliveryLoopDeps + ) + + loop.wake(SESSION) + + await vi.waitFor(() => + expect(log.entries.map((entry) => entry.fields)).toEqual([ + { scope: 'delivery-loop', sessionId: SESSION, error }, + { scope: 'delivery-loop-fail', sessionId: SESSION, error } + ]) + ) + }) + + it('the idle sweep logs a step that fails', async () => { + const log = recordingStructuredAgentSessionLogger() + const sweep = new StructuredAgentSessionIdleSweep( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a tick whose serialize rejects reads only these members. + { + sessions: new Map([[SESSION, {}]]), + isDisposed: () => false, + serialize: rejectingSerialize, + logger: log.logger + } as unknown as StructuredAgentSessionIdleSweepDeps + ) + + await sweep.tick() + + expect(log.entries.map((entry) => entry.fields)).toEqual([ + { scope: 'idle-sweep', sessionId: SESSION, error } + ]) + }) + + it('the queued-message drain logs a step that fails', async () => { + const log = recordingStructuredAgentSessionLogger() + const journal = { isReadOnly: false, queuedMessages: { settlementOwed: () => true } } + const drain = new StructuredAgentSessionQueuedMessageDrain( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a schedule past the owed-settlement check reads only these members. + { + sessions: new Map([[SESSION, { journal }]]), + serialize: rejectingSerialize, + logger: log.logger + } as unknown as QueuedMessageDrainDeps + ) + + drain.schedule(SESSION) + + await vi.waitFor(() => + expect(log.entries.map((entry) => entry.fields)).toEqual([ + { scope: 'queued-drain', sessionId: SESSION, error } + ]) + ) + }) +}) + +describe('a logger that throws', () => { + it('never throws out of the report, and still prints what failed', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const loggerError = new Error('logger broke') + const logger = neverThrowingStructuredAgentSessionLogger({ + warn: () => { + throw loggerError + }, + error: () => { + throw loggerError + } + }) + const error = new Error('what failed') + + expect(() => logger.warn('a step failed', { scope: 'step', error })).not.toThrow() + expect(() => logger.error('a step failed', { scope: 'step', error })).not.toThrow() + expect(warn).toHaveBeenCalledWith('[agent-session] a step failed', { + scope: 'step', + error, + loggerError + }) + warn.mockRestore() + }) +}) + +describe('the production logger', () => { + afterEach(() => { + _resetTracerForTests() + vi.restoreAllMocks() + }) + + it('writes each entry to the trace file as a failed span named for its scope', () => { + const push = vi.fn() + setActiveSink({ push, flush: () => {}, close: () => {} }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + createStructuredAgentSessionLogger().warn('settling a late dispatch failed', { + scope: 'late-settlement', + sessionId: SESSION, + error: new Error('disk full') + }) + + expect(push).toHaveBeenCalledOnce() + expect(push.mock.calls[0]?.[0]).toMatchObject({ + type: 'effect-span', + name: 'agentSession.late-settlement', + attributes: { + level: 'warn', + message: 'settling a late dispatch failed', + sessionId: SESSION + }, + exit: { _tag: 'Failure', cause: expect.stringContaining('Error: disk full') } + }) + }) +}) + +describe('installing the runtime', () => { + let stateDirectory: string | null = null + + afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (stateDirectory) { + await rm(stateDirectory, { recursive: true, force: true }) + stateDirectory = null + } + }) + + it('refuses without a logger rather than dropping every failure', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-logger-wiring-')) + stateDirectory = root + + await expect( + ensureStructuredAgentSessionHost( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: deliberately missing the logger, as a `@ts-nocheck` caller could drop it. + { + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + } as unknown as Parameters[0] + ) + ).rejects.toThrow(STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED) + }) + + it('hands the host a logger whose throw cannot reach the operation it reports', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-logger-wiring-')) + stateDirectory = root + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const host = await ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + logger: { + warn: () => { + throw new Error('logger broke') + }, + error: () => { + throw new Error('logger broke') + } + } + }) + + expect(() => host.deps.logger.warn('a step failed', { scope: 'step' })).not.toThrow() + }) +}) diff --git a/src/main/runtime/structured-agent-session-host-refusal.test.ts b/src/main/runtime/structured-agent-session-host-refusal.test.ts index d05c9a78422..3ef5965b4e8 100644 --- a/src/main/runtime/structured-agent-session-host-refusal.test.ts +++ b/src/main/runtime/structured-agent-session-host-refusal.test.ts @@ -25,8 +25,12 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' let root: string +let log = recordingStructuredAgentSessionLogger() +const openFailureLogs = () => + log.entries.filter((entry) => entry.fields.scope === 'journal-database-open') // An in-process caller: the same build as the host, so the gate asks it for no capability. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cleanup gate reads only `clientKind` and `clientCapabilities`. @@ -34,6 +38,7 @@ const IN_PROCESS = {} as RpcContext function install(): ReturnType { return ensureStructuredAgentSessionHost({ + logger: log.logger, stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', @@ -64,6 +69,7 @@ async function digest(path: string): Promise { beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-journal-host-refusal-')) + log = recordingStructuredAgentSessionLogger() }) afterEach(async () => { @@ -107,7 +113,6 @@ describe('a process whose journal will not open', () => { earlier.pragma('user_version = 2') earlier.close() const before = await digest(path) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) for (let attempt = 0; attempt < 3; attempt += 1) { await expect(install()).rejects.toMatchObject({ @@ -118,8 +123,8 @@ describe('a process whose journal will not open', () => { reason: 'journalCorrupt', message: 'Unable to load this chat.' }) - expect(warn).toHaveBeenCalledOnce() - const logged = String(warn.mock.calls[0]?.[1]) + expect(openFailureLogs()).toHaveLength(1) + const logged = String(openFailureLogs()[0]?.fields.error) expect(logged).toContain(path) expect(logged).toContain('unreleased development build') expect(logged).toContain('Moving the file aside') @@ -143,8 +148,6 @@ describe('a process whose journal will not open', () => { }) describe('logging a journal that will not open', () => { - const OPEN_FAILED = '[structured-agent-session] opening the chat journal database failed' - async function writeJunkJournal(): Promise { const path = journalDatabasePath(root) await rm(`${path}-wal`, { force: true }) @@ -154,8 +157,6 @@ describe('logging a journal that will not open', () => { // Every chat request retries the open; the same failure each time is one log, not one per request. it('logs a repeated failure once, with its stack, and again after an open succeeds', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const openFailureLogs = () => warn.mock.calls.filter(([message]) => message === OPEN_FAILED) await install() await stopStructuredAgentSessionRuntime() await writeJunkJournal() @@ -166,8 +167,11 @@ describe('logging a journal that will not open', () => { }) } expect(openFailureLogs()).toHaveLength(1) - expect(openFailureLogs()[0]?.[1]).toBeInstanceOf(Error) - expect(openFailureLogs()[0]?.[1]).toHaveProperty('stack', expect.stringContaining('\n')) + expect(openFailureLogs()[0]?.fields.error).toBeInstanceOf(Error) + expect(openFailureLogs()[0]?.fields.error).toHaveProperty( + 'stack', + expect.stringContaining('\n') + ) await unlink(journalDatabasePath(root)) await install() diff --git a/src/main/runtime/structured-agent-session-install-failure.test.ts b/src/main/runtime/structured-agent-session-install-failure.test.ts index 4b00cdebda6..c00e9c6890f 100644 --- a/src/main/runtime/structured-agent-session-install-failure.test.ts +++ b/src/main/runtime/structured-agent-session-install-failure.test.ts @@ -11,6 +11,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const mocks = vi.hoisted(() => ({ failWiring: vi.fn(() => false) })) @@ -32,6 +33,7 @@ let root: string function install(): ReturnType { return ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index cf41fdb1dfe..3d9e72f7404 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -43,6 +43,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const journals = createTrackedJournalOpener() @@ -330,6 +331,7 @@ beforeEach(async () => { publishStructuredAgentSessionTab: () => {}, ensureStructuredAgentSessionHost: () => ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', @@ -352,11 +354,9 @@ beforeEach(async () => { openCodexConnection: codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000 }).then(() => undefined), - registerOwnedSubscriptionCleanup: vi.fn((_id: string, dispose: () => void) => { - return { - releaseIfCurrent: dispose - } - }) + registerOwnedSubscriptionCleanup: vi.fn((_id: string, dispose: () => void) => ({ + releaseIfCurrent: dispose + })) } dispatcher = new RpcDispatcher({ runtime: runtime as unknown as OrcaRuntimeService, diff --git a/src/main/runtime/structured-agent-session-journal-open.ts b/src/main/runtime/structured-agent-session-journal-open.ts index 6dd9d964d48..54c27fbfe96 100644 --- a/src/main/runtime/structured-agent-session-journal-open.ts +++ b/src/main/runtime/structured-agent-session-journal-open.ts @@ -9,11 +9,12 @@ import { type LegacyAgentSessionRecordImportReport } from './agent-session-legacy-record-import' import { recordStructuredAgentSessionHostInstallRefusal } from './structured-agent-session-host-refusal' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' // Every chat request retries a failed open, so each distinct failure is logged once, with its stack. let lastLoggedOpenFailure: string | null = null -function logOpenFailureOnce(error: unknown): void { +function logOpenFailureOnce(logger: StructuredAgentSessionLogger, error: unknown): void { const failure = error instanceof Error ? `${'code' in error ? String(error.code) : ''}:${error.message}` @@ -22,7 +23,10 @@ function logOpenFailureOnce(error: unknown): void { return } lastLoggedOpenFailure = failure - console.warn('[structured-agent-session] opening the chat journal database failed', error) + logger.error('opening the chat journal database failed', { + scope: 'journal-database-open', + error + }) } /** The journal database. A refusal is recorded for the gate and thrown to the caller; the next @@ -30,24 +34,36 @@ function logOpenFailureOnce(error: unknown): void { export async function openStructuredAgentSessionJournalDatabase(args: { stateDirectory: string hostId: string - onLegacyRecordImportReport: (report: LegacyAgentSessionRecordImportReport) => void + logger: StructuredAgentSessionLogger }): Promise { try { const opened = await JournalHostDatabase.open(args.stateDirectory, async () => legacyAgentSessionRecordImport( await readLegacyAgentSessionRecords(args.stateDirectory, args.hostId), args.hostId, - args.onLegacyRecordImportReport + (report) => reportLegacyRecordImport(args.logger, report) ) ) recordStructuredAgentSessionHostInstallRefusal(null) lastLoggedOpenFailure = null return opened } catch (error) { - logOpenFailureOnce(error) + logOpenFailureOnce(args.logger, error) // Nothing is renamed, deleted or rebuilt: the file is left exactly as it is. const refusal = journalOpenRefusalError(error) recordStructuredAgentSessionHostInstallRefusal(refusal) throw refusal } } + +function reportLegacyRecordImport( + logger: StructuredAgentSessionLogger, + report: LegacyAgentSessionRecordImportReport +): void { + const { kind, ...fields } = report + logger.warn('importing the chat records file did not complete', { + scope: 'legacy-record-import', + outcome: kind, + ...fields + }) +} diff --git a/src/main/runtime/structured-agent-session-lifecycle-delivery.ts b/src/main/runtime/structured-agent-session-lifecycle-delivery.ts index 961c4d50aa8..ccf026de359 100644 --- a/src/main/runtime/structured-agent-session-lifecycle-delivery.ts +++ b/src/main/runtime/structured-agent-session-lifecycle-delivery.ts @@ -7,10 +7,11 @@ // serialized step, and is tracked here so the same drain still waits for it. import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' export function createStructuredAgentSessionLifecycleDelivery(input: { handle: (event: StructuredAgentSessionLifecycleEvent) => Promise | undefined - onError?: (input: { scope: string; error: unknown }) => void + logger: StructuredAgentSessionLogger /** Exits the adapter has observed but not yet published. */ drainObservedExits: () => Promise }): { @@ -24,8 +25,11 @@ export function createStructuredAgentSessionLifecycleDelivery(input: { try { await input.handle(event) } catch (error) { - const scope = event.type === 'started' ? 'started' : 'exit' - input.onError?.({ scope: `structured-agent-session-${scope}:${event.sessionId}`, error }) + input.logger.warn(`delivering a provider ${event.type} event to the host failed`, { + scope: event.type === 'started' ? 'lifecycle-started' : 'lifecycle-exit', + sessionId: event.sessionId, + error + }) } } return { diff --git a/src/main/runtime/structured-agent-session-record-import-install.test.ts b/src/main/runtime/structured-agent-session-record-import-install.test.ts index 551fab477c4..dd76d01e6c6 100644 --- a/src/main/runtime/structured-agent-session-record-import-install.test.ts +++ b/src/main/runtime/structured-agent-session-record-import-install.test.ts @@ -17,6 +17,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const NOW = 1_800_000_000_000 const IMPORTED = 'session-alpha-1' @@ -60,7 +61,7 @@ function databaseVersion(): number { } /** The runtime at startup, rooted at `root`, with its PTY daemon stubbed. */ -function startupRuntime(onError: (input: { scope: string; error: unknown }) => void) { +function startupRuntime(log: ReturnType) { const runtime = new OrcaRuntimeService() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root` and stubs the PTY daemon. const internal = runtime as unknown as { @@ -72,13 +73,13 @@ function startupRuntime(onError: (input: { scope: string; error: unknown }) => v internal.hasPersistedStructuredAgentSessionStore = () => true internal.ensureStructuredAgentSessionHost = () => ensureStructuredAgentSessionHost({ + logger: log.logger, stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), - onError + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) }) internal.refreshMobileSessionPtyRecords = async () => new Set() return { @@ -88,11 +89,11 @@ function startupRuntime(onError: (input: { scope: string; error: unknown }) => v } } -function importReports(onError: ReturnType): unknown[] { - return onError.mock.calls - .map(([input]) => input) - .filter((input) => input.scope === 'structured-agent-session-record-import') - .map((input) => input.error) +/** Each import report the install logged, its kind as the entry's outcome. */ +function importReports(log: ReturnType): unknown[] { + return log.entries + .filter((entry) => entry.fields.scope === 'legacy-record-import') + .map(({ fields: { scope: _scope, outcome, ...rest } }) => ({ kind: outcome, ...rest })) } // A read that can clear: the file's own permissions, as a locked-down or mid-restore profile has. @@ -101,13 +102,13 @@ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( async () => { await writeLegacy(legacyFile()) await chmod(legacyAgentSessionStorePath(root), 0o000) - const onError = vi.fn() - const first = startupRuntime(onError) + const log = recordingStructuredAgentSessionLogger() + const first = startupRuntime(log) await first.runtime.restoreStructuredAgentSessionTabs() const host = await first.host() - expect(importReports(onError)).toEqual([ + expect(importReports(log)).toEqual([ { kind: 'unavailable', error: expect.objectContaining({ message: 'agent_session_store_corrupt' }) @@ -142,7 +143,7 @@ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( await stopStructuredAgentSessionRuntime() await chmod(legacyAgentSessionStorePath(root), 0o600) - const second = startupRuntime(vi.fn()) + const second = startupRuntime(recordingStructuredAgentSessionLogger()) await second.runtime.restoreStructuredAgentSessionTabs() const relaunched = await second.host() @@ -156,12 +157,12 @@ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( it('installs over a file no read will make usable, reports it once, and leaves it untouched', async () => { await writeLegacy('{ truncated') - const onError = vi.fn() - const { runtime, host } = startupRuntime(onError) + const log = recordingStructuredAgentSessionLogger() + const { runtime, host } = startupRuntime(log) await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() - expect(importReports(onError)).toEqual([ + expect(importReports(log)).toEqual([ { kind: 'unusable', error: expect.objectContaining({ message: 'agent_session_store_corrupt' }) } ]) expect((await host()).legacyRecordImportOwed()).toBe(false) diff --git a/src/main/runtime/structured-agent-session-runtime-exit.test.ts b/src/main/runtime/structured-agent-session-runtime-exit.test.ts index 3da0c6f203d..2727b2389f2 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -17,6 +17,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' describe('structured session runtime provider-exit wiring', () => { let root: string | null = null @@ -80,6 +81,7 @@ describe('structured session runtime provider-exit wiring', () => { return connection }) as typeof openCodexAppServerConnection const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', @@ -184,6 +186,7 @@ describe('structured session runtime provider-exit wiring', () => { return connection }) as typeof openCodexAppServerConnection const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', @@ -214,6 +217,7 @@ describe('structured session runtime provider-exit wiring', () => { }) const restarted = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', @@ -288,6 +292,7 @@ describe('structured session runtime provider-exit wiring', () => { return connection }) as typeof openCodexAppServerConnection const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', @@ -394,6 +399,7 @@ describe('structured session runtime provider-exit wiring', () => { return connection } const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index e1d37c452a1..f572e890983 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -28,6 +28,7 @@ import { hasPersistedStructuredAgentSessionStore, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const HOST_ID = 'local' @@ -258,6 +259,7 @@ describe('structured agent-session runtime install', () => { it('holds stop until the model catalog has written its coalesced save', async () => { stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-')) await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory, hostId: HOST_ID, claimKeyId: 'key-1', @@ -297,6 +299,7 @@ describe('structured agent-session runtime install', () => { __setWindowsProcessTreeLoaderForTests(() => null) try { const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory, hostId: HOST_ID, claimKeyId: 'key-1', diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index aecf034b370..3785fdeaf5c 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -13,7 +13,6 @@ import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import { existsSync } from 'node:fs' import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { DISPATCH_DOUBT_PROVIDER_IDLE } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' import { structuredAgentSessionTeardownTrigger, @@ -53,7 +52,12 @@ import { createStructuredAgentEnvironmentResolvers } from './structured-agent-sh import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter' import { createStructuredAgentSessionLifecycleDelivery } from './structured-agent-session-lifecycle-delivery' +import { createStructuredAgentSessionDispatchFollowUps } from './structured-agent-session-dispatch-followups' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { + neverThrowingStructuredAgentSessionLogger, + type StructuredAgentSessionLogger +} from '../native-chat/agent-session-wire/structured-agent-session-logger' import { modelCatalogHostDeps, type RuntimeAgentAccountHomeResolver @@ -113,7 +117,9 @@ export type StructuredAgentSessionRuntimeDeps = { /** Which login-shell variables Codex and Claude children inherit; absent inherits all. */ resolveShellEnvironmentPolicy?: () => NativeChatShellEnvironmentPolicy resolveCodexOverrides?: () => NodeJS.ProcessEnv - onError?: (input: { scope: string; error: unknown }) => void + /** Where every failure the runtime and its host carry on past is reported. Required, so no path + * can drop one: the desktop and headless hosts both pass the trace-file logger. */ + logger: StructuredAgentSessionLogger /** Every structured-session status projection, for host-side reactions such as the first-work * workspace rename that CLI agents get from their hooks. */ onSessionStatusChanged?: StructuredAgentSessionHostDeps['onSessionStatusChanged'] @@ -132,6 +138,11 @@ let installing: Promise | null = null export const CLAUDE_STRUCTURED_AUTH_POLICY_REQUIRED = 'structured agent-session host requires a Claude auth policy resolver' +/** Thrown when the host is installed without a logger: every failure it carries on past would + * otherwise reach nobody. */ +export const STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED = + 'structured agent-session host requires a logger' + /** * Runtimes whose teardown did not finish. `installing` is cleared regardless so * nothing new attaches, but dropping the runtime as well would strand every @@ -206,16 +217,18 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise | undefined = deps.logger + if (typeof declared?.warn !== 'function' || typeof declared.error !== 'function') { + throw new Error(STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED) + } + const logger = neverThrowingStructuredAgentSessionLogger(deps.logger) const journalDatabase = await openStructuredAgentSessionJournalDatabase({ stateDirectory: deps.stateDirectory, hostId: deps.hostId, - onLegacyRecordImportReport: (report) => - deps.onError - ? deps.onError({ scope: 'structured-agent-session-record-import', error: report }) - : console.warn('[structured-agent-session] importing the chat records file', report) + logger }) try { - return await installOnJournal(deps, journalDatabase) + return await installOnJournal({ ...deps, logger }, journalDatabase) } catch (error) { // Nothing else holds the connection yet, and the next install opens its own. journalDatabase.close() @@ -233,35 +246,13 @@ async function installOnJournal( let host: StructuredAgentSessionHost | null = null const lifecycle = createStructuredAgentSessionLifecycleDelivery({ handle: (event) => host?.handleAdapterEvent(event), - ...(deps.onError ? { onError: deps.onError } : {}), + logger: deps.logger, // Claude publishes an observed exit only after its close ladder and transcript write; Codex // publishes inside its own exit callback and needs nothing. drainObservedExits: () => claude.drainObservedExits() }) - const onDispatchSettledLate = ( - settlement: Parameters[0] - ): void => { - void host?.settleLateDispatch(settlement).catch((error) => - deps.onError?.({ - scope: `structured-agent-session-late-settlement:${settlement.sessionId}`, - error - }) - ) - } - // The provider going idle is what re-derives a doubted send; a late echo still accepts it. - const releaseUnansweredDispatches = ({ sessionId }: { sessionId: string }): void => { - void host - ?.releaseUnansweredDispatches({ - sessionId, - reason: DISPATCH_DOUBT_PROVIDER_IDLE - }) - .catch((error) => - deps.onError?.({ - scope: `structured-agent-session-unanswered-dispatch:${sessionId}`, - error - }) - ) - } + const { onDispatchSettledLate, releaseUnansweredDispatches } = + createStructuredAgentSessionDispatchFollowUps({ host: () => host, logger: deps.logger }) const codex = new CodexStructuredSessionAdapter({ resolveLaunch: createCodexStructuredLaunchResolver({ store, @@ -327,12 +318,7 @@ async function installOnJournal( await deps.resolveLaunchArgs!(provider) } : {}), - onEventSinkError: ({ sessionId, error }) => - deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }), - onLeaseReconcileFailure: (error) => - deps.onError - ? deps.onError({ scope: 'structured-agent-session-lease-reconcile', error }) - : console.warn('[structured-agent-session] reconciling chat leases failed', error), + logger: deps.logger, ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), diff --git a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts index d86c54282c1..aee47ab15ba 100644 --- a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts +++ b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts @@ -17,6 +17,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' /** pid -> the NUL-separated environment block `/proc//environ` serves. */ const fakeProc = vi.hoisted(() => ({ environs: new Map() })) @@ -135,6 +136,7 @@ describe('a process that inherited a spawn token', () => { const kill = vi.spyOn(process, 'kill').mockImplementation(() => true) const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory, hostId: HOST_ID, claimKeyId: 'key-1', diff --git a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts index 32b30a5dc77..43d457790e3 100644 --- a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts +++ b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts @@ -21,6 +21,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const writes = vi.hoisted(() => ({ failing: false })) @@ -62,7 +63,7 @@ async function seedChat(options: { newer?: boolean } = {}) { return { path: journalDatabasePath(root), sessionId: record.sessionId } } -function startupRuntime(onError?: (input: { scope: string; error: unknown }) => void) { +function startupRuntime(log = recordingStructuredAgentSessionLogger()) { const runtime = new OrcaRuntimeService() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root` and stubs the PTY daemon. const internal = runtime as unknown as { @@ -73,13 +74,13 @@ function startupRuntime(onError?: (input: { scope: string; error: unknown }) => internal.hasPersistedStructuredAgentSessionStore = () => true internal.ensureStructuredAgentSessionHost = () => ensureStructuredAgentSessionHost({ + logger: log.logger, stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), - ...(onError ? { onError } : {}) + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) }) internal.refreshMobileSessionPtyRecords = async () => new Set() return runtime @@ -88,13 +89,13 @@ function startupRuntime(onError?: (input: { scope: string; error: unknown }) => it('finishes startup over records a newer Orca wrote, reads them, and writes nothing', async () => { const { path, sessionId } = await seedChat({ newer: true }) const bytes = await readFile(path) - const onError = vi.fn() - const runtime = startupRuntime(onError) + const log = recordingStructuredAgentSessionLogger() + const runtime = startupRuntime(log) // What the renderer's startup awaits through `app:prepareTerminalStartupRestoration`. await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() - expect(onError).not.toHaveBeenCalled() + expect(log.entries).toEqual([]) const host = getStructuredAgentSessionHost() expect(host?.sessionAgent(sessionId)).toBe('claude') // Adjudicated in memory only: the verdict is re-derived at the next start. @@ -105,45 +106,32 @@ it('finishes startup over records a newer Orca wrote, reads them, and writes not it('finishes startup when the reconcile cannot write, and reports it', async () => { const { sessionId } = await seedChat() - const onError = vi.fn() - const runtime = startupRuntime(onError) + const log = recordingStructuredAgentSessionLogger() + const runtime = startupRuntime(log) writes.failing = true await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() - expect(onError).toHaveBeenCalledOnce() - expect(onError).toHaveBeenCalledWith({ - scope: 'structured-agent-session-lease-reconcile', - error: expect.objectContaining({ message: 'disk I/O error' }) - }) + expect(log.entries).toEqual([ + expect.objectContaining({ + fields: { + scope: 'lease-reconcile', + error: expect.objectContaining({ message: 'disk I/O error' }) + } + }) + ]) expect(getStructuredAgentSessionHost()?.sessionAgent(sessionId)).toBe('claude') }) -// The desktop installs its host with no error sink, so the failure is logged rather than dropped. -it('logs the failure when the host has no error sink', async () => { - await seedChat() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - writes.failing = true - - await expect( - startupRuntime().prepareStructuredAgentSessionStartupRestoration() - ).resolves.toBeUndefined() - - expect(warn).toHaveBeenCalledWith( - '[structured-agent-session] reconciling chat leases failed', - expect.objectContaining({ message: 'disk I/O error' }) - ) -}) - // Closing a chat tab drops it from the restore index, which a newer Orca's records refuse: the // close is reported and goes on, since bookkeeping never keeps a tab open. it('closes a chat tab over records a newer Orca wrote, reporting the index it cannot write', async () => { const { sessionId } = await seedChat({ newer: true }) - const runtime = startupRuntime(vi.fn()) + const log = recordingStructuredAgentSessionLogger() + const runtime = startupRuntime(log) await runtime.prepareStructuredAgentSessionStartupRestoration() const host = getStructuredAgentSessionHost() const close = vi.spyOn(host!, 'close') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's own protected close path, reached with the one field it reads. const internal = runtime as unknown as { closeStructuredAgentSessionTab(tab: { sessionId: string }, cause: 'user-close'): Promise @@ -153,11 +141,16 @@ it('closes a chat tab over records a newer Orca wrote, reporting the index it ca internal.closeStructuredAgentSessionTab({ sessionId }, 'user-close') ).resolves.toBeUndefined() - expect(warn).toHaveBeenCalledWith( - '[structured-agent-session] recording a closed chat tab failed', - expect.objectContaining({ - refusal: expect.objectContaining({ details: { reason: 'journalWrittenByNewerOrca' } }) - }) - ) + expect(log.entries).toContainEqual({ + level: 'warn', + message: 'recording a closed chat tab failed', + fields: { + scope: 'tab-visibility-close', + sessionId, + error: expect.objectContaining({ + refusal: expect.objectContaining({ details: { reason: 'journalWrittenByNewerOrca' } }) + }) + } + }) expect(close).toHaveBeenCalledWith(sessionId, 'user-close') }) diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index 84c14b2e23c..ac6df4c4b2b 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -35,6 +35,8 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' // `failing` fails every record write; `grants` lets that many more through, then fails. const writes = vi.hoisted(() => ({ failing: false, grants: Infinity, refused: 0 })) @@ -134,9 +136,9 @@ async function seedProfile( }) ) const database = await openStructuredAgentSessionJournalDatabase({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, - hostId: 'local', - onLegacyRecordImportReport: () => undefined + hostId: 'local' }) for (const record of options.history ?? records) { const fence = record.lease.runtimeFence @@ -174,9 +176,9 @@ async function seedChatOpenedWhileOwed(record: AgentSessionRecord, tabId: string // A directory where the file belongs: the read fails in a way that can clear. await mkdir(legacyAgentSessionStorePath(root), { recursive: true }) const database = await openStructuredAgentSessionJournalDatabase({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, - hostId: 'local', - onLegacyRecordImportReport: () => undefined + hostId: 'local' }) database.db .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') @@ -190,7 +192,7 @@ async function seedChatOpenedWhileOwed(record: AgentSessionRecord, tabId: string } function startupRuntime(options: { afterInstall?: () => void; profileChats?: string[] } = {}) { - const onError = vi.fn() + const log = recordingStructuredAgentSessionLogger() const runtime = new OrcaRuntimeService() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root`, stubs the PTY daemon and gives it a profile. const internal = runtime as unknown as { @@ -203,13 +205,13 @@ function startupRuntime(options: { afterInstall?: () => void; profileChats?: str internal.hasPersistedStructuredAgentSessionStore = () => true internal.ensureStructuredAgentSessionHost = async () => { const installed = await ensureStructuredAgentSessionHost({ + logger: log.logger, stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), - onError + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) }) options.afterInstall?.() return installed @@ -241,7 +243,7 @@ function startupRuntime(options: { afterInstall?: () => void; profileChats?: str } return { runtime, - onError, + log, published: () => internal.mobileSessionTabsByWorktree.get('workspace-1')?.tabs ?? [] } } @@ -283,7 +285,7 @@ describe('restoring the chat tabs open at quit', () => { const tabWrites = spyOnTabWrites() vi.spyOn(console, 'warn').mockImplementation(() => undefined) // Writable when the store opens, then failing for good. - const { runtime, onError, published } = startupRuntime({ + const { runtime, log, published } = startupRuntime({ afterInstall: () => { writes.failing = writesFail } @@ -300,13 +302,16 @@ describe('restoring the chat tabs open at quit', () => { await expectHistory(CHAT_B) // A newer Orca's leases are adjudicated in memory, so nothing fails there. if (newer) { - expect(onError).not.toHaveBeenCalled() + expect(log.entries).toEqual([]) } else { - expect(onError).toHaveBeenCalledOnce() - expect(onError).toHaveBeenCalledWith({ - scope: 'structured-agent-session-lease-reconcile', - error: expect.objectContaining({ message: 'disk I/O error' }) - }) + expect(log.entries).toEqual([ + expect.objectContaining({ + fields: { + scope: 'lease-reconcile', + error: expect.objectContaining({ message: 'disk I/O error' }) + } + }) + ]) } expect(tabWrites.visibility).not.toHaveBeenCalled() expect(tabWrites.seed).not.toHaveBeenCalled() @@ -408,8 +413,7 @@ describe('restoring the chat tabs open at quit', () => { it('still lists the chats when that write fails, and leaves the index absent', async () => { const records = legacyChats() await seedProfile(records) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const { runtime, published } = startupRuntime({ profileChats: [CHAT_A, CHAT_B] }) + const { runtime, published, log } = startupRuntime({ profileChats: [CHAT_A, CHAT_B] }) await runtime.prepareStructuredAgentSessionStartupRestoration() writes.failing = true @@ -417,13 +421,15 @@ describe('restoring the chat tabs open at quit', () => { expect(published()).toHaveLength(2) await expectHistory(CHAT_A) - expect(warn).toHaveBeenCalledWith( - '[structured-agent-session] recording restored chat tabs failed', - { + expect(log.entries).toContainEqual({ + level: 'warn', + message: 'recording restored chat tabs failed', + fields: { + scope: 'tab-index-seed', sessionIds: [CHAT_A, CHAT_B], error: expect.objectContaining({ message: 'disk I/O error' }) } - ) + }) expect(await tabIndexOnDisk()).toBeUndefined() }) }) diff --git a/src/main/runtime/structured-agent-session-tab-index-seed.ts b/src/main/runtime/structured-agent-session-tab-index-seed.ts index 188a419f31e..3e2245148a2 100644 --- a/src/main/runtime/structured-agent-session-tab-index-seed.ts +++ b/src/main/runtime/structured-agent-session-tab-index-seed.ts @@ -11,7 +11,7 @@ type TabIndexHost = Pick< /** Best-effort: the tabs publish either way, and an index still absent is seeded again next launch. */ export async function seedStructuredAgentSessionTabIndex( - host: Partial | null | undefined, + host: (Partial & Pick) | null | undefined, targets: readonly string[], restored: readonly string[] ): Promise { @@ -27,7 +27,8 @@ export async function seedStructuredAgentSessionTabIndex( ) if (unlisted.length > 0) { await host.showSessionTabs(unlisted).catch((error: unknown) => - console.warn('[structured-agent-session] recording restored chat tabs failed', { + host.deps.logger.warn('recording restored chat tabs failed', { + scope: 'tab-index-seed', sessionIds: unlisted, error }) diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 7b2f0d2fe85..c9c3b8448ad 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -42,6 +42,7 @@ import { resetProviderFaults, type FakeConnection } from './structured-chat-coordinator-fake-codex-fixture' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const COORDINATOR = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' const PEER_CHAT = '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64' @@ -255,6 +256,7 @@ beforeEach(async () => { db = new OrchestrationDb(':memory:') runtime = startRuntime() host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts index ea3559e3059..eaf5644164f 100644 --- a/src/main/runtime/structured-child-records-switch.test.ts +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -33,6 +33,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) @@ -117,6 +118,7 @@ describe('the chat strip and the session list read the same host child records', readChildWork: (subject) => server.getStructuredChildWorkViews(subject) } const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', diff --git a/src/main/runtime/structured-claude-pending-rewind.test.ts b/src/main/runtime/structured-claude-pending-rewind.test.ts index c521ef8b6d9..47fa6d20100 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -24,6 +24,7 @@ import type { AgentSessionRecordStore } from './agent-session-record-store' import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const caller = { callerKey: 'desktop' } const PROVIDER_SESSION_ID = claudeSessionIdForOrcaSession(HOST_TEST_SESSION) @@ -37,6 +38,7 @@ let store: AgentSessionRecordStore let claude: ReturnType let adapter: ReturnType let host: StructuredAgentSessionHost +let log: ReturnType function attachParams(fence: number | null) { return hostTestAttachParams(fence, { @@ -118,7 +120,9 @@ beforeEach(async () => { readProcessStartTime: async () => HOST_TEST_NOW, onLifecycleEvent: () => {} }) + log = recordingStructuredAgentSessionLogger() host = new StructuredAgentSessionHost({ + logger: log.logger, store, // Only Claude sessions are attached here; the router supplies the production create gate. adapter: new StructuredAgentSessionAdapterRouter({ claude: adapter, codex: adapter }, () => @@ -173,7 +177,6 @@ describe('Claude rewind is unsupported', () => { ) it('still attaches when settling the pending rewind fails, and logs it', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) await seedPendingRewind('prepared') const transition = store.transitionHandoff.bind(store) vi.spyOn(store, 'transitionHandoff').mockImplementation((sessionId, apply) => @@ -189,9 +192,14 @@ describe('Claude rewind is unsupported', () => { await reattach() expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('prepared') - expect(warn).toHaveBeenCalledWith( - '[structured-rewind] pending Claude rewind was not settled:', - expect.objectContaining({ sessionId: HOST_TEST_SESSION, error: expect.any(Error) }) + expect(log.entries).toContainEqual( + expect.objectContaining({ + fields: expect.objectContaining({ + scope: 'rewind-unsupported-settlement', + sessionId: HOST_TEST_SESSION, + error: expect.any(Error) + }) + }) ) }) }) diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index 07de5e58761..7a69cbe9c86 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -20,6 +20,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' /** The error a real CLI's exit reaches the adapter as: Orca's message, the stderr as a log detail. */ export function scriptedClaudeExitError(diagnostic: string): Error { @@ -223,6 +224,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { await mkdir(join(root, 'claude-home'), { recursive: true }) const directory = root return ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: directory, hostId: 'local', claimKeyId: 'key-1', diff --git a/src/main/runtime/structured-codex-child-work-runtime.test.ts b/src/main/runtime/structured-codex-child-work-runtime.test.ts index d03ab6f1be5..5e0cf36f14d 100644 --- a/src/main/runtime/structured-codex-child-work-runtime.test.ts +++ b/src/main/runtime/structured-codex-child-work-runtime.test.ts @@ -19,6 +19,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const THREAD = 'thread-runtime-child-work' const CHILD = 'thread-runtime-reviewer' @@ -70,6 +71,7 @@ describe('structured Codex child work through the production runtime', () => { NonNullable >[] = [] const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', diff --git a/src/main/runtime/structured-codex-session-rpc-test-harness.ts b/src/main/runtime/structured-codex-session-rpc-test-harness.ts index 9d6c0ef30fb..81e7d3f1d8c 100644 --- a/src/main/runtime/structured-codex-session-rpc-test-harness.ts +++ b/src/main/runtime/structured-codex-session-rpc-test-harness.ts @@ -28,6 +28,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' export const SESSION = 'session-integration-1' export const THREAD = 'thread-integration' @@ -206,6 +207,7 @@ export async function openStructuredCodexRpcHarness( }) }) const hostConfig = (): Parameters[0] => ({ + logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', diff --git a/src/main/runtime/structured-session-worktree-teardown.ts b/src/main/runtime/structured-session-worktree-teardown.ts index 3f8ab31b3ec..047b4a21644 100644 --- a/src/main/runtime/structured-session-worktree-teardown.ts +++ b/src/main/runtime/structured-session-worktree-teardown.ts @@ -336,12 +336,14 @@ export async function retireStructuredSessionTabsForWorktree( * proved after that close had already rolled the hide back. */ async function dropDurableChatTabReference(sessionId: string): Promise { + const host = getStructuredAgentSessionHost() try { - await getStructuredAgentSessionHost()?.setSessionTabVisibility?.(sessionId, false) + await host?.setSessionTabVisibility?.(sessionId, false) } catch (error) { - console.warn( - `[worktree-teardown] could not drop the chat tab reference for ${sessionId}`, + host?.deps.logger.warn('dropping a removed workspace chat tab reference failed', { + scope: 'teardown-tab-drop', + sessionId, error - ) + }) } } diff --git a/tests/e2e/codex-child-approval-activity-row.unit.test.ts b/tests/e2e/codex-child-approval-activity-row.unit.test.ts index c2df9b1f3e3..27f00644ec1 100644 --- a/tests/e2e/codex-child-approval-activity-row.unit.test.ts +++ b/tests/e2e/codex-child-approval-activity-row.unit.test.ts @@ -71,6 +71,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { StructuredAgentSessionStatusBridge } from '../../src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge' import { resetStructuredAgentSessionStatusFeedsForTests } from '../../src/renderer/src/runtime/structured-agent-session-status-feed' +import { createStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' +import { testEventSinkLogging } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger-test-support' const SESSION = 'codex-child-approval' const CODEX_THREAD = 'thread-parent' @@ -143,6 +145,7 @@ async function openHost() { stateDirectory: join(root, SESSION) }) const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([ [SESSION, indexedStatusFeedSession({ journal, child: { phase: 'ready' } })] ]), @@ -151,7 +154,7 @@ async function openHost() { }) const events: AgentSessionStatusEvent[] = [] feed.subscribe({ id: 'renderer', emit: (event) => events.push(event) }) - const deferred = createDeferredStructuredAgentSessionEventSink() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const publish = (): void => feed.publish(SESSION, journal) deferred.bind({ journal, fence: 1, publish }) const prompts: string[] = [] diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 4aaa02037ec..4969357ba87 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -62,6 +62,7 @@ import { type RpcReply } from './versioned-agent-session-wire' import { openTestJournalHostDatabase } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' // Why: a cold CI run extracts the baseline checkout before the first pairing. const SUITE_TIMEOUT_MS = 180_000 @@ -475,6 +476,7 @@ describe('cross-version structured agent sessions', () => { root = await mkdtemp(join(tmpdir(), 'orca-cross-version-ai-vault-')) store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: { acquire: async ({ fence }) => ({ @@ -701,6 +703,7 @@ describe('cross-version structured agent sessions', () => { async function bootHost(generation: string): Promise { store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), diff --git a/tests/e2e/structured-agent-session-read-owner.unit.test.ts b/tests/e2e/structured-agent-session-read-owner.unit.test.ts index dafa6e8089d..b7734f2759d 100644 --- a/tests/e2e/structured-agent-session-read-owner.unit.test.ts +++ b/tests/e2e/structured-agent-session-read-owner.unit.test.ts @@ -32,6 +32,7 @@ import { getStructuredAgentSessionReadOwner, resetStructuredAgentSessionReadOwnersForTests } from '../../src/renderer/src/components/native-chat/structured-agent-session-read-owner' +import { createStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' const SESSION = 'cursor-body-regression' const target = { kind: 'local' } as const @@ -125,6 +126,7 @@ describe('structured session cursor/body regression', () => { const { journal, appendOutput, accept } = await fixture() let hostSummary: AgentSessionStatusSummary | undefined const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([ [ SESSION, diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts index e656b320b62..a5827937358 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -25,6 +25,7 @@ import { runWorktreeAgentActivationGate } from '../../src/renderer/src/lib/workt import { readWorktreeStructuredActivationInventory } from '../../src/renderer/src/lib/worktree-agent-structured-inventory' import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' import { openTestJournalHostDatabase } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' const WORKTREE = 'repo-1::/workspace/repo' @@ -36,6 +37,7 @@ let closeSession: Mock ({