From 09c6eb3833c56ff3c04902f3f1df241d1c08c876 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Fri, 12 Jun 2026 14:15:28 -0700 Subject: [PATCH 1/3] fix(daemon): reconstruct session state for events on unknown sessions The daemon idles out after ~10 min and holds all session state in memory, seeded only at SessionStart. A Claude Code session that outlives a daemon restart sends its next UserPromptSubmit to a fresh daemon that never saw its SessionStart, which logged "Unknown session" and silently dropped tracing for the rest of that session (159 such errors over 14 days in one local log; 52 distinct sessions observed straddling a restart). Reconstruct the session from the transcript_path every hook event carries, seeding the turn counter from the turns already on disk so numbering continues. Makes the daemon tolerant of its own restarts. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/daemon.ts | 132 +++++++++++++++----- tests/daemon-session-reconstruction.test.ts | 89 +++++++++++++ 2 files changed, 193 insertions(+), 28 deletions(-) create mode 100644 tests/daemon-session-reconstruction.test.ts diff --git a/src/daemon.ts b/src/daemon.ts index 26d97e0..217cd9f 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -769,33 +769,10 @@ export class GlobalDaemon { const conversationId = await this.resolveConversationId(sessionId, transcript.resolvedPath, source); - // Claude Code stamps its CLI version on each transcript line; capture it - // best-effort from the head line for the integration metadata. Absent when - // the writer hasn't flushed yet — the meta key is simply omitted. - const headLine = readFirstTranscriptLine(transcript.resolvedPath); - const claudeCodeAppVersion = - typeof headLine?.['version'] === 'string' ? (headLine['version'] as string) : undefined; - const integrationBaggage = createIntegrationBaggage({ - version: VERSION, - meta: { claude_code_app_version: claudeCodeAppVersion }, - }); - - this.sessions.set(sessionId, { + this.sessions.set( sessionId, - conversationId, - transcript, - cwd, - source, - initialRequestModel, - integrationBaggage, - turnNumber: 0, - totalToolCalls: 0, - turnToolCalls: 0, - toolCounts: {}, - pendingToolCalls: new Map(), - subagents: new SubagentTracking(), - emittedChatSpanResponseKeys: new Set(), - }); + this.newSessionState(sessionId, conversationId, transcript, cwd, source, initialRequestModel, 0), + ); const resumed = conversationId !== sessionId; this.log('INFO', `Session created: ${sessionId}${resumed ? ` (resumed; conversation=${conversationId})` : ''}`); @@ -880,10 +857,109 @@ export class GlobalDaemon { return current; } + /** Build a fresh SessionState. `turnNumber` seeds the turn counter: 0 for a + * brand-new session, or the number of turns already on disk when + * reconstructing a session lost across a daemon restart (so the resumed turn + * keeps counting up instead of resetting to 1). */ + private newSessionState( + sessionId: string, + conversationId: string, + transcript: TranscriptFile, + cwd: string, + source: string, + initialRequestModel: string | undefined, + turnNumber: number, + ): SessionState { + // Claude Code stamps its CLI version on each transcript line; capture it + // best-effort from the head line for the integration metadata. Absent when + // the writer hasn't flushed yet — the meta key is simply omitted. Built + // here (not at the SessionStart call site) so a session reconstructed after + // a daemon restart carries the same integration identity on its spans. + const headLine = readFirstTranscriptLine(transcript.resolvedPath); + const claudeCodeAppVersion = + typeof headLine?.['version'] === 'string' ? (headLine['version'] as string) : undefined; + const integrationBaggage = createIntegrationBaggage({ + version: VERSION, + meta: { claude_code_app_version: claudeCodeAppVersion }, + }); + + return { + sessionId, + conversationId, + transcript, + cwd, + source, + initialRequestModel, + integrationBaggage, + turnNumber, + totalToolCalls: 0, + turnToolCalls: 0, + toolCounts: {}, + pendingToolCalls: new Map(), + subagents: new SubagentTracking(), + emittedChatSpanResponseKeys: new Set(), + }; + } + + /** + * Return the tracked session, reconstructing it from the event's + * `transcript_path` when this daemon never saw its SessionStart. The daemon + * idles out after a short quiet window and keeps all session state in memory; + * Claude Code only emits SessionStart on startup/resume/clear/compact, so a + * session that outlives a daemon restart would otherwise be permanently + * untraced (the "Unknown session" errors). Every hook event carries + * `transcript_path`, which is enough to rebuild state and resume tracing. + */ + private async getOrReconstructSession( + sessionId: string, + payload: HookPayload, + ): Promise { + const existing = this.sessions.get(sessionId); + if (existing) return existing; + + const rawPath = payload['transcript_path'] as string | undefined; + if (!rawPath) return undefined; + + let transcript: TranscriptFile; + try { + transcript = new TranscriptFile(rawPath); + } catch (err) { + this.log('ERROR', `Cannot reconstruct session ${sessionId}: invalid transcript_path: ${err}`); + return undefined; + } + + const source = (payload['source'] as string | undefined) ?? 'reconstructed'; + const cwd = (payload['cwd'] as string | undefined) ?? ''; + const initialRequestModel = payload['model'] as string | undefined; + const conversationId = await this.resolveConversationId(sessionId, transcript.resolvedPath, source); + + // Seed the turn counter from the turns already on disk so numbering + // continues across the restart instead of resetting to 1. + let priorTurns = 0; + try { + priorTurns = parseSessionFd(transcript.getFd())?.turns.length ?? 0; + } catch (err) { + this.log('DEBUG', `Reconstruct ${sessionId}: could not count prior turns: ${err}`); + } + + const session = this.newSessionState( + sessionId, conversationId, transcript, cwd, source, initialRequestModel, priorTurns, + ); + this.sessions.set(sessionId, session); + this.log( + 'INFO', + `Session reconstructed after restart: ${sessionId} (conversation=${conversationId}, prior_turns=${priorTurns})`, + ); + return session; + } + private async handleUserPromptSubmit(sessionId: string, payload: HookPayload): Promise { - const session = this.sessions.get(sessionId); + // Reconstruct the session if this daemon never saw its SessionStart (e.g. it + // idled out mid-session and a fresh daemon took over) so the rest of the + // session stays traced instead of dropping with "Unknown session". + const session = await this.getOrReconstructSession(sessionId, payload); if (!session) { - this.log('ERROR', `Unknown session: ${sessionId}`); + this.log('ERROR', `Unknown session (no transcript_path to reconstruct): ${sessionId}`); return; } if (!this.tracer) return; diff --git a/tests/daemon-session-reconstruction.test.ts b/tests/daemon-session-reconstruction.test.ts new file mode 100644 index 0000000..0c18e25 --- /dev/null +++ b/tests/daemon-session-reconstruction.test.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +// The daemon shuts itself down after a short idle window and keeps all session +// state in memory, seeded only at SessionStart. A Claude Code session that +// outlives a daemon restart (e.g. the user steps away >10 min, the daemon idles +// out, then they resume the SAME session) sends its next UserPromptSubmit to a +// fresh daemon that never saw its SessionStart — producing "Unknown session" +// and silently dropping all tracing for the rest of that session. +// +// The fix: reconstruct the session from the `transcript_path` carried on the +// event, so the daemon is tolerant of its own restarts. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { startTestDaemon } from './helpers.ts'; + +/** Write a transcript with `turns` completed user+assistant pairs and return + * its path. Lives under the daemon's $HOME so TranscriptFile's within-home + * check passes. */ +function writeTranscript(home: string, sessionId: string, turns: number): string { + const dir = path.join(home, '.claude', 'projects', 'test', sessionId); + fs.mkdirSync(dir, { recursive: true }); + const lines: string[] = []; + for (let i = 0; i < turns; i++) { + lines.push(JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: `prompt ${i}` }] } })); + lines.push(JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', model: 'claude-opus-4-8', id: `m${i}`, + usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + stop_reason: 'end_turn', content: [{ type: 'text', text: `answer ${i}` }], + }, + })); + } + const file = path.join(dir, `${sessionId}.jsonl`); + fs.writeFileSync(file, lines.join('\n') + '\n'); + return file; +} + +test('UserPromptSubmit for an unknown session reconstructs it from transcript_path and opens a turn span', async () => { + const d = await startTestDaemon(); + try { + const sessionId = 'recon-sess-001'; + const transcript = writeTranscript(d.home, sessionId, 1); + + // No SessionStart — this session predates this daemon instance. + await d.send({ + hook_event_name: 'UserPromptSubmit', + session_id: sessionId, + transcript_path: transcript, + prompt: 'continue the work', + }); + + const traced = await d.waitForLog(/Created turn span/, 3000); + assert.ok(traced, `expected a turn span for the reconstructed session; log was:\n${d.readLog()}`); + + const log = d.readLog(); + assert.match(log, /Session reconstructed after restart: recon-sess-001/); + assert.doesNotMatch(log, /Unknown session/); + } finally { + await d.stop(); + } +}); + +test('reconstructed session continues turn numbering from the transcript', async () => { + const d = await startTestDaemon(); + try { + const sessionId = 'recon-sess-002'; + // Three completed turns already on disk → the resumed turn is turn 4. + const transcript = writeTranscript(d.home, sessionId, 3); + + await d.send({ + hook_event_name: 'UserPromptSubmit', + session_id: sessionId, + transcript_path: transcript, + prompt: 'fourth prompt', + }); + + const ok = await d.waitForLog(/Created turn span \(turn 4\)/, 3000); + assert.ok(ok, `expected the reconstructed turn to be numbered 4; log was:\n${d.readLog()}`); + } finally { + await d.stop(); + } +}); From 1651521a40e9f0db6667ddd1529aa2464d900c13 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Wed, 1 Jul 2026 10:49:28 -0700 Subject: [PATCH 2/3] feat(daemon): raise idle-reap timeout 10m to 120m The idle timeout only fires when nothing is in flight (INFLIGHT_HOLD_MAX_MS already keeps an active turn/tool/team alive), so it purely governs how long an idle daemon stays warm. At 10m a normal think-time gap reaped the daemon and stranded the resumed session on a fresh one, the dominant "Unknown session" trigger this PR also reconstructs from. 120m keeps the daemon warm across the gaps in a working session (long build, meeting, lunch); longer idle still reaps and is recovered by reconstruction. Env-overridable via WEAVE_INACTIVITY_MS. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/daemon.ts | 10 +++++++++- tests/daemon-session-reconstruction.test.ts | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/daemon.ts b/src/daemon.ts index 217cd9f..59571a8 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -334,7 +334,15 @@ type SessionState = { // GlobalDaemon // ───────────────────────────────────────────────────────────────────────────── -const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1_000; // 10 minutes +// How long the daemon stays alive with no hook events before self-reaping. It +// only fires when nothing is in flight (the INFLIGHT_HOLD_MAX_MS guards keep an +// active turn/tool/team alive regardless), so this window purely governs how +// long an idle daemon stays warm for the next prompt. Set to 120 min so gaps in +// a working session (a long build, a meeting, lunch) don't reap the daemon and +// strand the resumed session on a fresh, amnesiac one, the dominant source of +// "Unknown session" drops. Longer idle gaps still reap; session reconstruction +// then recovers those. Override with WEAVE_INACTIVITY_MS. +const INACTIVITY_TIMEOUT_MS = 120 * 60 * 1_000; // 120 minutes // Absolute ceiling for holding the daemon open past the normal inactivity // timeout while work is still in flight — either cross-session team // correlation (hasUnemittedTeamMembers) or an ordinary open turn / pending diff --git a/tests/daemon-session-reconstruction.test.ts b/tests/daemon-session-reconstruction.test.ts index 0c18e25..1697553 100644 --- a/tests/daemon-session-reconstruction.test.ts +++ b/tests/daemon-session-reconstruction.test.ts @@ -4,7 +4,7 @@ // The daemon shuts itself down after a short idle window and keeps all session // state in memory, seeded only at SessionStart. A Claude Code session that -// outlives a daemon restart (e.g. the user steps away >10 min, the daemon idles +// outlives a daemon restart (e.g. the user steps away, the daemon idles // out, then they resume the SAME session) sends its next UserPromptSubmit to a // fresh daemon that never saw its SessionStart — producing "Unknown session" // and silently dropping all tracing for the rest of that session. From 838c6d3f1d915955e22fd44536a7fe97a05b7fb2 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Thu, 2 Jul 2026 10:09:08 -0700 Subject: [PATCH 3/3] refactor(daemon): extract newSessionState to a module-level fn with named options Address review on #92: pull newSessionState out of the class (it never touched `this`), take a single options object instead of 7 positional args (cwd/source/initialRequestModel were easy to swap), and narrow the CLI version via an extracted const so the `as string` cast drops out. No behavior change; build + full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/daemon.ts | 115 +++++++++++++++++++++++++++++--------------------- 1 file changed, 67 insertions(+), 48 deletions(-) diff --git a/src/daemon.ts b/src/daemon.ts index 59571a8..dfc4c8b 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -423,6 +423,55 @@ class SubagentTracking { } } +/** Options for {@link newSessionState}. `turnNumber` seeds the turn counter: 0 + * for a brand-new session, or the number of turns already on disk when + * reconstructing a session lost across a daemon restart (so the resumed turn + * keeps counting up instead of resetting to 1). */ +type NewSessionStateOptions = { + sessionId: string; + conversationId: string; + transcript: TranscriptFile; + cwd: string; + source: string; + initialRequestModel: string | undefined; + turnNumber: number; +}; + +/** Build a fresh SessionState. */ +function newSessionState(options: NewSessionStateOptions): SessionState { + const { sessionId, conversationId, transcript, cwd, source, initialRequestModel, turnNumber } = + options; + // Claude Code stamps its CLI version on each transcript line; capture it + // best-effort from the head line for the integration metadata. Absent when + // the writer hasn't flushed yet, the meta key is simply omitted. Built + // here (not at the SessionStart call site) so a session reconstructed after + // a daemon restart carries the same integration identity on its spans. + const headLine = readFirstTranscriptLine(transcript.resolvedPath); + const version = headLine?.['version']; + const claudeCodeAppVersion = typeof version === 'string' ? version : undefined; + const integrationBaggage = createIntegrationBaggage({ + version: VERSION, + meta: { claude_code_app_version: claudeCodeAppVersion }, + }); + + return { + sessionId, + conversationId, + transcript, + cwd, + source, + initialRequestModel, + integrationBaggage, + turnNumber, + totalToolCalls: 0, + turnToolCalls: 0, + toolCounts: {}, + pendingToolCalls: new Map(), + subagents: new SubagentTracking(), + emittedChatSpanResponseKeys: new Set(), + }; +} + export class GlobalDaemon { private server?: net.Server; private running = false; @@ -779,7 +828,15 @@ export class GlobalDaemon { this.sessions.set( sessionId, - this.newSessionState(sessionId, conversationId, transcript, cwd, source, initialRequestModel, 0), + newSessionState({ + sessionId, + conversationId, + transcript, + cwd, + source, + initialRequestModel, + turnNumber: 0, + }), ); const resumed = conversationId !== sessionId; @@ -865,50 +922,6 @@ export class GlobalDaemon { return current; } - /** Build a fresh SessionState. `turnNumber` seeds the turn counter: 0 for a - * brand-new session, or the number of turns already on disk when - * reconstructing a session lost across a daemon restart (so the resumed turn - * keeps counting up instead of resetting to 1). */ - private newSessionState( - sessionId: string, - conversationId: string, - transcript: TranscriptFile, - cwd: string, - source: string, - initialRequestModel: string | undefined, - turnNumber: number, - ): SessionState { - // Claude Code stamps its CLI version on each transcript line; capture it - // best-effort from the head line for the integration metadata. Absent when - // the writer hasn't flushed yet — the meta key is simply omitted. Built - // here (not at the SessionStart call site) so a session reconstructed after - // a daemon restart carries the same integration identity on its spans. - const headLine = readFirstTranscriptLine(transcript.resolvedPath); - const claudeCodeAppVersion = - typeof headLine?.['version'] === 'string' ? (headLine['version'] as string) : undefined; - const integrationBaggage = createIntegrationBaggage({ - version: VERSION, - meta: { claude_code_app_version: claudeCodeAppVersion }, - }); - - return { - sessionId, - conversationId, - transcript, - cwd, - source, - initialRequestModel, - integrationBaggage, - turnNumber, - totalToolCalls: 0, - turnToolCalls: 0, - toolCounts: {}, - pendingToolCalls: new Map(), - subagents: new SubagentTracking(), - emittedChatSpanResponseKeys: new Set(), - }; - } - /** * Return the tracked session, reconstructing it from the event's * `transcript_path` when this daemon never saw its SessionStart. The daemon @@ -950,9 +963,15 @@ export class GlobalDaemon { this.log('DEBUG', `Reconstruct ${sessionId}: could not count prior turns: ${err}`); } - const session = this.newSessionState( - sessionId, conversationId, transcript, cwd, source, initialRequestModel, priorTurns, - ); + const session = newSessionState({ + sessionId, + conversationId, + transcript, + cwd, + source, + initialRequestModel, + turnNumber: priorTurns, + }); this.sessions.set(sessionId, session); this.log( 'INFO',