diff --git a/src/daemon.ts b/src/daemon.ts index 26d97e0..dfc4c8b 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 @@ -415,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; @@ -769,33 +826,18 @@ 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(), - }); + newSessionState({ + sessionId, + conversationId, + transcript, + cwd, + source, + initialRequestModel, + turnNumber: 0, + }), + ); const resumed = conversationId !== sessionId; this.log('INFO', `Session created: ${sessionId}${resumed ? ` (resumed; conversation=${conversationId})` : ''}`); @@ -880,10 +922,71 @@ export class GlobalDaemon { return current; } + /** + * 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 = newSessionState({ + sessionId, + conversationId, + transcript, + cwd, + source, + initialRequestModel, + turnNumber: 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..1697553 --- /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, 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(); + } +});