feat(desktop): add debug trace instrumentation

A device-local toggle (Settings → Advanced → Debug trace logging) that,
when enabled, dumps structured console.debug entries for every stateful
event in the desktop app:

- Session state transitions (busy/needsInput/storedSessionId edges)
- Compaction start/finish per session
- message.complete events (session id, message count, usage/billing)
- Session switches (activeSessionId + selectedStoredSessionId changes)
- Compression id rotation (the spookiest bug class — route/pin/draft key
  silently changes mid-turn)
- Persistence writes (key, op, truncated value preview)
- All gateway events (type, session id, payload — deltas summarized)
- Gateway connection state (idle→open→closed) + connection mode/profile
- Profile switches ($activeGatewayProfile changes)
- Resume failures + exhaustion (the #1 'stuck on loading' signal)
- Busy/awaitingResponse edges
- Message array length changes (count only, not per-token)
- Error boundary catches (React render crashes with componentStack)
- Blocking prompts: clarify/approval/sudo/secret raised/cleared edges
- Sessions list length changes (new/archive/delete/merge)

Zero cost when disabled: every debugTrace() call early-returns on the
$debugTraceEnabled atom, and the subscriptions (persistence, gateway
events, atom watchers) are no-op closures when tracing is off.

Pattern follows keep-awake: device-local localStorage atom, side-effect
import in main.tsx, ToggleRow in Settings → Advanced. i18n strings in
all four locales (en/ja/zh/zh-hant).
This commit is contained in:
ethernet 2026-07-23 16:54:10 -04:00
parent d44674fe08
commit a71a90bc73
12 changed files with 386 additions and 4 deletions

View file

@ -11,6 +11,7 @@ import { translateNow } from '@/i18n'
import { type GatewayEventPayload, textPart } from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
import { playCompletionSound } from '@/lib/completion-sound'
import { debugTrace } from '@/lib/debug-trace'
import { resolveGatewayEventSessionId } from '@/lib/gateway-events'
import { triggerHaptic } from '@/lib/haptics'
import { modelOptionsQueryKey } from '@/lib/model-options'
@ -34,6 +35,7 @@ import {
$currentCwd,
$currentModel,
$currentProvider,
$messages,
sessionMatchesStoredId,
setCurrentBranch,
setCurrentCwd,
@ -541,6 +543,17 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
return
}
debugTrace(
'message',
`complete session=${sessionId}`,
{
isActive: isActiveEvent,
messageCount: $messages.get().length,
hasUsage: Boolean(payload?.usage),
hasBilling: Boolean(payload?.billing)
}
)
// Turn ended — drop any blocking prompt still open for THIS session
// (e.g. interrupted, or the approval already resolved). Scoped to the
// session so a background turn finishing can't wipe the active chat's

View file

@ -7,6 +7,7 @@ import { useSearchParams } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { $debugTraceEnabled, setDebugTraceEnabled } from '@/lib/debug-trace'
import { $keepAwake, setKeepAwake } from '@/store/keep-awake'
import { notify, notifyError } from '@/store/notifications'
import { repoDiscoveryPolicyFromConfig, repoDiscoveryPolicySignature, scanAndRecordRepos } from '@/store/projects'
@ -57,6 +58,7 @@ export function ConfigSettings({
const { t } = useI18n()
const c = t.settings.config
const keepAwake = useStore($keepAwake)
const debugTraceEnabled = useStore($debugTraceEnabled)
// The editable draft is local (debounced autosave watches it), but it's seeded
// from — and saved back through — the shared config cache, so edits are visible
// in the MCP/model surfaces and reopening the page doesn't reload-flash.
@ -293,6 +295,9 @@ export function ConfigSettings({
{activeSectionId === 'advanced' && (
<ToggleRow checked={keepAwake} description={c.keepAwakeDesc} label={c.keepAwakeTitle} onChange={setKeepAwake} />
)}
{activeSectionId === 'advanced' && (
<ToggleRow checked={debugTraceEnabled} description={c.debugTraceDesc} label={c.debugTraceTitle} onChange={setDebugTraceEnabled} />
)}
{visibleFields.length === 0 ? (
<EmptyState description={c.emptyDesc} title={c.emptyTitle} />
) : (

View file

@ -2,6 +2,7 @@ import { Component, type ErrorInfo, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { ErrorState } from '@/components/ui/error-state'
import { debugTrace } from '@/lib/debug-trace'
import { useI18n } from '@/i18n'
export interface ErrorBoundaryFallbackProps {
@ -30,6 +31,10 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
componentDidCatch(error: Error, info: ErrorInfo) {
const tag = this.props.label ? `[error-boundary:${this.props.label}]` : '[error-boundary]'
console.error(tag, error, info.componentStack)
debugTrace('error-boundary', `${this.props.label ?? 'root'}: ${error.message}`, {
error,
componentStack: info.componentStack
})
this.props.onError?.(error, info)
}

View file

@ -541,7 +541,9 @@ export const en: Translations = {
imported: 'Config imported',
invalidJson: 'Invalid config JSON',
keepAwakeTitle: 'Keep computer awake',
keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.'
keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.',
debugTraceTitle: 'Debug trace logging',
debugTraceDesc: 'Log verbose state transitions, session switches, compaction events, and gateway events to the devtools console for bug reproduction.'
},
credentials: {
pasteKey: 'Paste key',

View file

@ -648,7 +648,9 @@ export const ja = defineLocale({
imported: '設定をインポートしました',
invalidJson: '設定 JSON が無効です',
keepAwakeTitle: 'コンピューターをスリープさせない',
keepAwakeDesc: '本体のスリープを防ぎ、長時間や夜通しの実行を継続します。画面は暗転できます。'
keepAwakeDesc: '本体のスリープを防ぎ、長時間や夜通しの実行を継続します。画面は暗転できます。',
debugTraceTitle: 'デバッグトレースログ',
debugTraceDesc: 'バグ再現用に、状態遷移・セッション切替・圧縮イベント・ゲートウェイイベントをdevtoolsコンソールに詳細出力します。'
},
credentials: {
pasteKey: 'キーを貼り付け',

View file

@ -448,6 +448,8 @@ export interface Translations {
invalidJson: string
keepAwakeTitle: string
keepAwakeDesc: string
debugTraceTitle: string
debugTraceDesc: string
}
credentials: {
pasteKey: string

View file

@ -636,7 +636,9 @@ export const zhHant = defineLocale({
imported: '設定已匯入',
invalidJson: '設定 JSON 無效',
keepAwakeTitle: '保持電腦喚醒',
keepAwakeDesc: '阻止本機睡眠,讓長時間或整夜執行持續進行。螢幕仍可變暗。'
keepAwakeDesc: '阻止本機睡眠,讓長時間或整夜執行持續進行。螢幕仍可變暗。',
debugTraceTitle: '除錯追蹤日誌',
debugTraceDesc: '將狀態轉換、工作階段切換、壓縮事件和閘道事件詳細輸出到 devtools 控制台,用於重現 bug。'
},
credentials: {
pasteKey: '貼上金鑰',

View file

@ -748,7 +748,9 @@ export const zh: Translations = {
imported: '配置已导入',
invalidJson: '配置 JSON 无效',
keepAwakeTitle: '保持电脑唤醒',
keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。'
keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。',
debugTraceTitle: '调试追踪日志',
debugTraceDesc: '将状态转换、会话切换、压缩事件和网关事件详细输出到 devtools 控制台,用于复现 bug。'
},
credentials: {
pasteKey: '粘贴密钥',

View file

@ -0,0 +1,328 @@
/**
* Debug trace optional, verbose instrumentation of stateful desktop events.
*
* When enabled (Settings Advanced), dumps structured `console.debug` entries
* for every session state transition, compaction event, message completion,
* session switch, persistence write, and gateway event. The output lands in the
* renderer devtools console and any attached log capture, so you can point at
* the trace after reproducing a bug.
*
* Zero cost when disabled: every call site is a single function call that
* early-returns when the atom is off. The subscriptions (persistence, gateway
* events, atom watchers) are only attached once at module init, and their
* callbacks also early-return so the overhead is one closure call per event,
* which is negligible.
*/
import { atom } from 'nanostores'
import { onGatewayEvent } from '@/contrib/events'
import { onPersistenceEvent } from '@/lib/storage'
import { $activeGatewayProfile } from '@/store/profile'
import {
$activeSessionId,
$activeSessionStoredIdRotation,
$awaitingResponse,
$busy,
$connection,
$gatewayState,
$messages,
$resumeExhaustedSessionId,
$resumeFailedSessionId,
$selectedStoredSessionId,
$sessions
} from '@/store/session'
import { $clarifyRequest } from '@/store/clarify'
import { $approvalRequest, $secretRequest, $sudoRequest } from '@/store/prompts'
const KEY = 'hermes.desktop.debugTrace.v1'
/** Device-local preference — off by default, per machine. */
export const $debugTraceEnabled = atom<boolean>(
typeof window === 'undefined' ? false : (() => {
try {
return window.localStorage.getItem(KEY) === 'true'
} catch {
return false
}
})()
)
export function setDebugTraceEnabled(on: boolean): void {
$debugTraceEnabled.set(on)
}
if (typeof window !== 'undefined') {
$debugTraceEnabled.subscribe(on => {
try {
window.localStorage.setItem(KEY, String(on))
} catch {
// Storage best-effort.
}
})
}
export type DebugCategory =
| 'session-state'
| 'compaction'
| 'message'
| 'session-switch'
| 'persistence'
| 'gateway-event'
| 'connection'
| 'profile-switch'
| 'resume'
| 'busy'
| 'error-boundary'
| 'prompt'
| 'sessions-list'
const CATEGORY_PREFIX: Record<DebugCategory, string> = {
'session-state': '[trace:session-state]',
compaction: '[trace:compaction]',
message: '[trace:message]',
'session-switch': '[trace:session-switch]',
persistence: '[trace:persistence]',
'gateway-event': '[trace:gateway-event]',
connection: '[trace:connection]',
'profile-switch': '[trace:profile-switch]',
resume: '[trace:resume]',
busy: '[trace:busy]',
'error-boundary': '[trace:error-boundary]',
prompt: '[trace:prompt]',
'sessions-list': '[trace:sessions-list]'
}
/**
* Emit a debug trace entry. No-ops entirely when tracing is disabled.
*
* `data` is spread as additional console arguments (not stringified) so
* devtools can expand/inspect objects natively.
*/
export function debugTrace(
category: DebugCategory,
message: string,
...data: unknown[]
): void {
if (!$debugTraceEnabled.get()) {
return
}
const ts = new Date().toISOString()
// eslint-disable-next-line no-console
console.debug(`${CATEGORY_PREFIX[category]} ${ts} ${message}`, ...data)
}
// ---------------------------------------------------------------------------
// Subscriptions — attached once at module init, no-op when disabled.
// ---------------------------------------------------------------------------
if (typeof window !== 'undefined') {
// --- Session switches ---
let prevActive: string | null = null
$activeSessionId.subscribe(id => {
if (id !== prevActive) {
debugTrace('session-switch', `activeSessionId ${prevActive ?? 'null'}${id ?? 'null'}`)
prevActive = id
}
})
let prevSelected: string | null = null
$selectedStoredSessionId.subscribe(id => {
if (id !== prevSelected) {
debugTrace('session-switch', `selectedStoredSessionId ${prevSelected ?? 'null'}${id ?? 'null'}`)
prevSelected = id
}
})
// --- Persistence events ---
onPersistenceEvent(event => {
const valuePreview =
event.value === null
? 'null'
: event.value.length > 120
? `${event.value.slice(0, 120)}…(${event.value.length} chars)`
: event.value
debugTrace('persistence', `${event.op} ${event.key}`, { value: valuePreview })
})
// --- Gateway events (wildcard) ---
onGatewayEvent('*', event => {
const type = event.type ?? 'unknown'
const raw = event as unknown as Record<string, unknown>
const sessionId = raw.session_id ?? raw.sessionId ?? null
// Summarize — don't dump the full payload for high-frequency deltas.
const summary: Record<string, unknown> = { type }
if (sessionId) {
summary.sessionId = sessionId
}
// For message.delta, just note it happened (they fire 30×/s during a turn).
// For everything else, include the payload for inspection.
if (type === 'message.delta') {
summary.note = 'delta (streaming)'
} else {
summary.payload = event
}
debugTrace('gateway-event', type, summary)
})
// --- Gateway connection state ---
let prevGatewayState: string | undefined
$gatewayState.subscribe(state => {
if (state !== prevGatewayState) {
debugTrace('connection', `gatewayState ${prevGatewayState ?? 'undefined'}${state}`)
prevGatewayState = state
}
})
// --- Connection (mode/baseUrl/profile) ---
let prevConnMode: string | undefined
let prevConnProfile: string | undefined
$connection.subscribe(conn => {
const mode = conn?.mode ?? 'null'
const profile = conn?.profile ?? 'null'
if (mode !== prevConnMode || profile !== prevConnProfile) {
debugTrace('connection', `connection mode=${mode} profile=${profile} baseUrl=${conn?.baseUrl ?? 'null'}`)
prevConnMode = mode
prevConnProfile = profile
}
})
// --- Profile switches ---
let prevProfile: string | undefined
$activeGatewayProfile.subscribe(profile => {
if (profile !== prevProfile) {
debugTrace('profile-switch', `${prevProfile ?? 'undefined'}${profile}`)
prevProfile = profile
}
})
// --- Resume failures + exhaustion ---
let prevResumeFailed: string | null = null
$resumeFailedSessionId.subscribe(id => {
if (id !== prevResumeFailed) {
debugTrace('resume', `resumeFailedSessionId ${prevResumeFailed ?? 'null'}${id ?? 'null'}`)
prevResumeFailed = id
}
})
let prevResumeExhausted: string | null = null
$resumeExhaustedSessionId.subscribe(id => {
if (id !== prevResumeExhausted) {
debugTrace('resume', `resumeExhaustedSessionId ${prevResumeExhausted ?? 'null'}${id ?? 'null'}`)
prevResumeExhausted = id
}
})
// --- Busy / awaitingResponse edges ---
let prevBusy = false
$busy.subscribe(busy => {
if (busy !== prevBusy) {
debugTrace('busy', `busy ${prevBusy}${busy}`, { activeSessionId: $activeSessionId.get() })
prevBusy = busy
}
})
let prevAwaiting = false
$awaitingResponse.subscribe(awaiting => {
if (awaiting !== prevAwaiting) {
debugTrace('busy', `awaitingResponse ${prevAwaiting}${awaiting}`, { activeSessionId: $activeSessionId.get() })
prevAwaiting = awaiting
}
})
// --- Message array length changes ---
// Not per-token (that'd be insane) — only when the count changes, which
// captures: new message added, transcript cleared, session switched,
// reconciliation replaced the array.
let prevMessageCount = -1
$messages.subscribe(messages => {
const count = messages.length
if (count !== prevMessageCount) {
debugTrace('message', `messages count ${prevMessageCount}${count}`, {
activeSessionId: $activeSessionId.get()
})
prevMessageCount = count
}
})
// --- Compression id rotation ---
// Fires when auto-compaction rotates the active session's stored id mid-turn.
// One of the spookiest bug classes — the route / pin / draft key silently
// changes under the user.
$activeSessionStoredIdRotation.subscribe(rotation => {
if (rotation) {
debugTrace('session-switch', `compression id rotation`, {
prev: rotation.previousStoredSessionId,
next: rotation.nextStoredSessionId,
runtime: rotation.runtimeSessionId,
isActive: rotation.runtimeSessionId === $activeSessionId.get()
})
}
})
// --- Blocking prompts (clarify / approval / sudo / secret) ---
// When these appear/disappear, the chat is blocked. Tracing the edges
// catches "agent silently stalled" bugs.
let prevClarify: unknown = null
$clarifyRequest.subscribe(req => {
if (req !== prevClarify) {
debugTrace('prompt', `clarify ${prevClarify ? 'cleared' : 'raised'}`, {
sessionId: $activeSessionId.get(),
requestId: req ? 'present' : 'null'
})
prevClarify = req
}
})
let prevApproval: unknown = null
$approvalRequest.subscribe(req => {
if (req !== prevApproval) {
debugTrace('prompt', `approval ${prevApproval ? 'cleared' : 'raised'}`, {
sessionId: $activeSessionId.get()
})
prevApproval = req
}
})
let prevSudo: unknown = null
$sudoRequest.subscribe(req => {
if (req !== prevSudo) {
debugTrace('prompt', `sudo ${prevSudo ? 'cleared' : 'raised'}`, {
sessionId: $activeSessionId.get()
})
prevSudo = req
}
})
let prevSecret: unknown = null
$secretRequest.subscribe(req => {
if (req !== prevSecret) {
debugTrace('prompt', `secret ${prevSecret ? 'cleared' : 'raised'}`, {
sessionId: $activeSessionId.get()
})
prevSecret = req
}
})
// --- Sessions list length changes ---
// Captures: new session created, session archived/deleted, sidebar merge
// kept/dropped a row. Not per-field — just the count edge.
let prevSessionsCount = -1
$sessions.subscribe(list => {
const count = list.length
if (count !== prevSessionsCount) {
debugTrace('sessions-list', `sessions count ${prevSessionsCount}${count}`)
prevSessionsCount = count
}
})
}

View file

@ -1,6 +1,9 @@
import './styles.css'
// Side-effect: applies the persisted window translucency on load.
import './store/translucency'
// Side-effect: attaches debug trace subscriptions (persistence, gateway
// events, session switch watchers). No-ops entirely when tracing is disabled.
import './lib/debug-trace'
import { QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'

View file

@ -1,5 +1,6 @@
import { atom, computed } from 'nanostores'
import { debugTrace } from '@/lib/debug-trace'
import { $activeSessionId } from './session'
// Per-session flag while auto-compaction runs mid-turn. Without it the
@ -25,6 +26,8 @@ export function setSessionCompacting(sessionId: string | null | undefined, activ
$compactingSessions.set({ ...sessions, [key]: true })
debugTrace('compaction', `started session=${key}`, { isActive: key === $activeSessionId.get() })
return
}
@ -35,4 +38,6 @@ export function setSessionCompacting(sessionId: string | null | undefined, activ
const next = { ...sessions }
delete next[key]
$compactingSessions.set(next)
debugTrace('compaction', `finished session=${key}`, { isActive: key === $activeSessionId.get() })
}

View file

@ -27,6 +27,7 @@ import {
noteActiveTreeGroup,
revealTreePane
} from '@/components/pane-shell/tree/store'
import { debugTrace } from '@/lib/debug-trace'
import { stableArray } from '@/lib/stable-array'
import { readJson, writeJson } from '@/lib/storage'
@ -189,6 +190,18 @@ export function publishSessionState(runtimeId: string, state: ClientSessionState
const prev = $sessionStates.get()[runtimeId] ?? null
$sessionStates.set({ ...$sessionStates.get(), [runtimeId]: state })
handleTransition(prev, state, runtimeId)
debugTrace(
'session-state',
`publish ${runtimeId}`,
{
prev: prev
? { storedSessionId: prev.storedSessionId, busy: prev.busy, needsInput: prev.needsInput }
: null,
next: { storedSessionId: state.storedSessionId, busy: state.busy, needsInput: state.needsInput },
isActive: runtimeId === $activeSessionId.get()
}
)
}
export function dropSessionState(runtimeId: string) {