Skip to content

Commit d91ea3b

Browse files
committed
chore: ensure resumed converation logged into the same trace.
1 parent 3bdfce8 commit d91ea3b

2 files changed

Lines changed: 211 additions & 3 deletions

File tree

src/daemon.ts

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { uuidv7 } from 'uuidv7';
1111
import { loadSettings } from './setup.js';
1212
import { appendToLog, deepEqual } from './utils.js';
1313
import { parseSessionFile } from './parser.js';
14+
import { TraceRegistry } from './traceRegistry.js';
1415

1516
// ─────────────────────────────────────────────────────────────────────────────
1617
// Types
@@ -77,6 +78,12 @@ interface SessionState {
7778
subagentByAgentId: Map<string, SubagentTracker>;
7879
}
7980

81+
interface TraceResolution {
82+
traceId: string;
83+
sessionCallId?: string;
84+
source: 'new' | 'registry-session' | 'registry-transcript';
85+
}
86+
8087
// ─────────────────────────────────────────────────────────────────────────────
8188
// GlobalDaemon
8289
// ─────────────────────────────────────────────────────────────────────────────
@@ -91,6 +98,7 @@ export class GlobalDaemon {
9198
private sessions = new Map<string, SessionState>();
9299
private sessionQueues = new Map<string, Promise<void>>();
93100
private weaveClient: WeaveClient | null = null;
101+
private traceRegistry = new TraceRegistry();
94102

95103
constructor(
96104
private readonly socketPath: string,
@@ -100,6 +108,9 @@ export class GlobalDaemon {
100108
) {}
101109

102110
async start(): Promise<void> {
111+
const loadedRegistryEntries = this.traceRegistry.load();
112+
this.log('DEBUG', `Loaded trace registry: ${loadedRegistryEntries} entries`);
113+
103114
// Initialize Weave client if a project is configured
104115
if (this.weaveProject) {
105116
try {
@@ -248,21 +259,39 @@ export class GlobalDaemon {
248259
return;
249260
}
250261

262+
const source = (payload['source'] as string | undefined) ?? 'unknown';
263+
const model = (payload['model'] as string | undefined) ?? 'unknown';
264+
const cwd = (payload['cwd'] as string | undefined) ?? '';
265+
const traceResolution = await this.resolveTraceForSession(sessionId, transcriptPath, source);
266+
const existingTurnCount = parseSessionFile(transcriptPath)?.turns.length ?? 0;
267+
251268
this.sessions.set(sessionId, {
252269
sessionId,
253270
transcriptPath,
254-
cwd: (payload['cwd'] as string | undefined) ?? '',
255-
traceId: uuidv7(),
256-
turnNumber: 0,
271+
cwd,
272+
traceId: traceResolution.traceId,
273+
sessionCallId: traceResolution.sessionCallId,
274+
turnNumber: existingTurnCount,
257275
totalToolCalls: 0,
258276
turnToolCalls: 0,
259277
toolCounts: {},
260278
pendingToolCalls: new Map(),
261279
subagentTrackers: new Map(),
262280
subagentByAgentId: new Map(),
263281
});
282+
this.upsertTraceRegistry(
283+
sessionId,
284+
traceResolution.traceId,
285+
transcriptPath,
286+
source,
287+
traceResolution.sessionCallId,
288+
);
264289

265290
this.log('INFO', `Session created: ${sessionId}`);
291+
this.log(
292+
'DEBUG',
293+
`SessionStart details: session=${sessionId} source=${source} model=${model} cwd=${cwd || '(empty)'} transcript_path=${transcriptPath} transcript_file=${path.basename(transcriptPath)} trace_id=${this.sessions.get(sessionId)?.traceId} trace_resolution=${traceResolution.source} existing_turns=${existingTurnCount} active_sessions=${this.sessions.size}`,
294+
);
266295
}
267296

268297
private async handleUserPromptSubmit(sessionId: string, payload: HookPayload): Promise<void> {
@@ -273,6 +302,10 @@ export class GlobalDaemon {
273302
}
274303

275304
const prompt = (payload['prompt'] as string | undefined) ?? '';
305+
this.log(
306+
'DEBUG',
307+
`UserPromptSubmit: session=${sessionId} trace_id=${session.traceId} existing_session_call=${session.sessionCallId ?? 'none'} current_turn_call=${session.currentTurnCallId ?? 'none'} turn_number=${session.turnNumber} prompt=${GlobalDaemon.promptSnippet(prompt, 120)}`,
308+
);
276309

277310
// Create the top-level session call on the first turn
278311
if (!session.sessionCallId && this.weaveClient) {
@@ -294,6 +327,13 @@ export class GlobalDaemon {
294327
},
295328
});
296329
this.log('INFO', `Created session call: ${callId}`);
330+
this.upsertTraceRegistry(
331+
session.sessionId,
332+
session.traceId,
333+
session.transcriptPath,
334+
'session_call_created',
335+
session.sessionCallId,
336+
);
297337
}
298338

299339
// Create a turn call for every prompt
@@ -575,6 +615,11 @@ export class GlobalDaemon {
575615
const currentTurn = parsedSession?.turns[parsedSession.turns.length - 1];
576616
const usage = currentTurn?.totalUsage();
577617
const model = currentTurn?.primaryModel();
618+
const transcriptTurns = parsedSession?.turns.length ?? 0;
619+
this.log(
620+
'DEBUG',
621+
`Stop: session=${sessionId} trace_id=${session.traceId} turn_call=${session.currentTurnCallId} transcript_path=${session.transcriptPath} transcript_turns=${transcriptTurns} parsed_model=${model ?? 'unknown'} last_assistant_message_present=${Boolean(payload['last_assistant_message'])}`,
622+
);
578623

579624
// Weave expects summary.usage keyed by model name: { "model-name": { input_tokens, output_tokens } }
580625
const usageSummary = usage && model ? { [model]: usage } : {};
@@ -586,6 +631,7 @@ export class GlobalDaemon {
586631
output: { assistant_message: (payload['last_assistant_message'] as string | undefined) ?? '' },
587632
summary: { usage: usageSummary, tool_count: session.turnToolCalls },
588633
});
634+
session.currentTurnCallId = undefined;
589635

590636
this.log('INFO', `Finished turn ${session.turnNumber} (${session.turnToolCalls} tools)`);
591637
}
@@ -594,6 +640,18 @@ export class GlobalDaemon {
594640
const session = this.sessions.get(sessionId);
595641
if (!session) return;
596642

643+
this.log(
644+
'DEBUG',
645+
`SessionEnd: session=${sessionId} trace_id=${session.traceId} reason=${(payload['reason'] as string | undefined) ?? 'unknown'} transcript_path=${session.transcriptPath} turns=${session.turnNumber} total_tools=${session.totalToolCalls} pending_tools=${session.pendingToolCalls.size} open_subagents=${session.subagentByAgentId.size}`,
646+
);
647+
this.upsertTraceRegistry(
648+
session.sessionId,
649+
session.traceId,
650+
session.transcriptPath,
651+
(payload['reason'] as string | undefined) ?? 'session_end',
652+
session.sessionCallId,
653+
);
654+
597655
if (this.weaveClient) {
598656
const now = new Date().toISOString();
599657

@@ -744,6 +802,48 @@ export class GlobalDaemon {
744802
this.sessionQueues.set(sessionId, next);
745803
}
746804

805+
private upsertTraceRegistry(
806+
sessionId: string,
807+
traceId: string,
808+
transcriptPath: string,
809+
source: string,
810+
sessionCallId?: string,
811+
): void {
812+
try {
813+
this.traceRegistry.upsert(sessionId, traceId, transcriptPath, source, sessionCallId);
814+
} catch (err) {
815+
this.log('ERROR', `Failed to update trace registry: ${err}`);
816+
}
817+
}
818+
819+
private async resolveTraceForSession(
820+
sessionId: string,
821+
transcriptPath: string,
822+
sessionSource: string,
823+
): Promise<TraceResolution> {
824+
if (sessionSource === 'resume') {
825+
const bySession = this.traceRegistry.getBySessionId(sessionId);
826+
if (bySession) {
827+
return {
828+
traceId: bySession.traceId,
829+
sessionCallId: bySession.sessionCallId,
830+
source: 'registry-session',
831+
};
832+
}
833+
834+
const byTranscript = this.traceRegistry.getByTranscriptPath(transcriptPath);
835+
if (byTranscript) {
836+
return {
837+
traceId: byTranscript.traceId,
838+
sessionCallId: byTranscript.sessionCallId,
839+
source: 'registry-transcript',
840+
};
841+
}
842+
}
843+
844+
return { traceId: uuidv7(), source: 'new' };
845+
}
846+
747847
private log(level: 'DEBUG' | 'INFO' | 'ERROR', msg: string): void {
748848
if (level === 'DEBUG' && !this.debugEnabled) return;
749849
appendToLog(this.logFile, level, msg);

src/traceRegistry.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// SPDX-FileCopyrightText: 2026 CoreWeave, Inc.
2+
// SPDX-License-Identifier: MIT
3+
// SPDX-PackageName: weave-claude-plugin
4+
5+
import * as fs from 'fs';
6+
import * as os from 'os';
7+
import * as path from 'path';
8+
9+
export interface TraceRegistryEntry {
10+
sessionId: string;
11+
traceId: string;
12+
sessionCallId?: string;
13+
transcriptPath: string;
14+
createdAt: string;
15+
lastSeenAt: string;
16+
lastSource: string;
17+
}
18+
19+
const TRACE_REGISTRY_FILE = path.join(os.homedir(), '.weave_claude_plugin', 'trace-registry.json');
20+
const TRACE_REGISTRY_MAX_ENTRIES = 5_000;
21+
const TRACE_REGISTRY_MAX_AGE_MS = 180 * 24 * 60 * 60 * 1_000; // 180 days
22+
23+
/**
24+
* Small local cache of Claude session -> Weave trace mappings.
25+
* Keeps continuation fast and available even when Weave reads are unavailable.
26+
*/
27+
export class TraceRegistry {
28+
private entries = new Map<string, TraceRegistryEntry>();
29+
30+
/** Load the on-disk registry into memory, prune stale entries, and return the retained count. */
31+
load(): number {
32+
try {
33+
if (!fs.existsSync(TRACE_REGISTRY_FILE)) return 0;
34+
const raw = JSON.parse(fs.readFileSync(TRACE_REGISTRY_FILE, 'utf8')) as { entries?: TraceRegistryEntry[] };
35+
this.entries.clear();
36+
for (const entry of raw.entries ?? []) {
37+
if (entry.sessionId && entry.traceId && entry.transcriptPath) {
38+
this.entries.set(entry.sessionId, entry);
39+
}
40+
}
41+
this.prune();
42+
return this.entries.size;
43+
} catch {
44+
this.entries.clear();
45+
return 0;
46+
}
47+
}
48+
49+
/** Look up a previously retained trace mapping by Claude's session ID. */
50+
getBySessionId(sessionId: string): TraceRegistryEntry | undefined {
51+
return this.entries.get(sessionId);
52+
}
53+
54+
/** Look up a previously retained trace mapping by transcript path as a fallback key. */
55+
getByTranscriptPath(transcriptPath: string): TraceRegistryEntry | undefined {
56+
return Array.from(this.entries.values()).find((entry) => entry.transcriptPath === transcriptPath);
57+
}
58+
59+
/** Create or refresh a session-to-trace mapping and persist the registry to disk. */
60+
upsert(
61+
sessionId: string,
62+
traceId: string,
63+
transcriptPath: string,
64+
source: string,
65+
sessionCallId?: string,
66+
): void {
67+
const now = new Date().toISOString();
68+
const existing = this.entries.get(sessionId);
69+
this.entries.set(sessionId, {
70+
sessionId,
71+
traceId,
72+
sessionCallId: sessionCallId ?? existing?.sessionCallId,
73+
transcriptPath,
74+
createdAt: existing?.createdAt ?? now,
75+
lastSeenAt: now,
76+
lastSource: source,
77+
});
78+
this.save();
79+
}
80+
81+
/** Persist the current registry contents after applying pruning rules. */
82+
private save(): void {
83+
this.prune();
84+
fs.mkdirSync(path.dirname(TRACE_REGISTRY_FILE), { recursive: true });
85+
const entries = Array.from(this.entries.values()).sort((a, b) => a.lastSeenAt.localeCompare(b.lastSeenAt));
86+
fs.writeFileSync(TRACE_REGISTRY_FILE, JSON.stringify({ version: 1, entries }, null, 2));
87+
fs.chmodSync(TRACE_REGISTRY_FILE, 0o600);
88+
}
89+
90+
/** Drop old entries and cap the registry size so the file stays bounded over time. */
91+
private prune(): void {
92+
const cutoff = Date.now() - TRACE_REGISTRY_MAX_AGE_MS;
93+
for (const [sessionId, entry] of this.entries) {
94+
if (Date.parse(entry.lastSeenAt) < cutoff) {
95+
this.entries.delete(sessionId);
96+
}
97+
}
98+
99+
if (this.entries.size <= TRACE_REGISTRY_MAX_ENTRIES) return;
100+
101+
// Futher prune entries that are over TRACE_REGISTRY_MAX_ENTRIES
102+
const entries = Array.from(this.entries.values()).sort((a, b) => a.lastSeenAt.localeCompare(b.lastSeenAt));
103+
const excess = entries.length - TRACE_REGISTRY_MAX_ENTRIES;
104+
for (const entry of entries.slice(0, excess)) {
105+
this.entries.delete(entry.sessionId);
106+
}
107+
}
108+
}

0 commit comments

Comments
 (0)