From 7187dd0da00871719fecac03f45d15ab4eceb1dc Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:59:12 +0530 Subject: [PATCH] fix(optimize): exclude sidechains from session heuristics (#974) --- README.md | 6 + src/optimize.ts | 49 ++++-- src/parser.ts | 2 + src/session-cache.ts | 2 +- src/types.ts | 5 + tests/optimize-sidechains.test.ts | 237 ++++++++++++++++++++++++++++ tests/parser-subagent-range.test.ts | 14 +- 7 files changed, 301 insertions(+), 14 deletions(-) create mode 100644 tests/optimize-sidechains.test.ts diff --git a/README.md b/README.md index 2159e1da..8988a10d 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,12 @@ codeburn optimize --format json # setup health + findings as JSON `codeburn optimize` scans your sessions and your `~/.claude/` setup for waste patterns: +For Claude Code, the optimize session count and session-level findings below +use user-started (main) sessions. Subagent sidechain transcripts are excluded +from that population because their delegated context and delivery behavior are +structurally different; their tokens, calls, and cost still count in all spend +totals and configuration-overhead findings. + - Files Claude re-reads across sessions (same content, same context, over and over) - Low Read:Edit ratio (editing without reading leads to retries and wasted tokens) - Wasted bash output (uncapped `BASH_MAX_OUTPUT_LENGTH`, trailing noise) diff --git a/src/optimize.ts b/src/optimize.ts index 63d49330..254231b2 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -1,10 +1,11 @@ import chalk from 'chalk' -import { isReadShapedBashCommand } from './bash-utils.js' +import { createHash } from 'node:crypto' import { readdir, stat } from 'fs/promises' import { existsSync, statSync } from 'fs' import { basename, join } from 'path' import { homedir } from 'os' +import { isReadShapedBashCommand } from './bash-utils.js' import { readSessionLines, readSessionFileSync } from './fs-utils.js' import { discoverAllSessions } from './providers/index.js' import { parseJsonlLine, shouldSkipLine } from './parser.js' @@ -2484,6 +2485,22 @@ function sessionTokenTotal(session: ProjectSummary['sessions'][number]): number + session.totalCacheWriteTokens } +// Sidechain transcripts are real usage, so they stay in project totals and in +// token/cost calibration. They are not user-started sessions, however, and +// should never enter optimize heuristics whose unit is a human work session. +// Keep that distinction local to optimize instead of deleting sidechains from +// ProjectSummary, which would under-report the work delegated to subagents. +function isOptimizeSession(session: ProjectSummary['sessions'][number]): boolean { + return session.isSidechain !== true +} + +function optimizeSessionCount(projects: ProjectSummary[]): number { + return projects.reduce( + (total, project) => total + project.sessions.filter(isOptimizeSession).length, + 0, + ) +} + function sessionEffectiveContextTokens(session: ProjectSummary['sessions'][number]): number { return session.totalInputTokens + session.totalCacheReadTokens * CACHE_READ_DISCOUNT @@ -2592,6 +2609,7 @@ export function findLowWorthCandidates(projects: ProjectSummary[]): LowWorthCand for (const project of projects) { for (const session of project.sessions) { + if (!isOptimizeSession(session)) continue if (session.totalCostUSD < WORTH_IT_MIN_COST_USD) continue if (sessionDeliveryCommand(session)) continue @@ -2692,7 +2710,7 @@ export function findContextBloatCandidates(projects: ProjectSummary[]): ContextB const candidates: ContextBloatCandidate[] = [] for (const project of projects) { - const sessions = [...project.sessions].sort((a, b) => + const sessions = project.sessions.filter(isOptimizeSession).sort((a, b) => new Date(a.firstTimestamp).getTime() - new Date(b.firstTimestamp).getTime() ) let previousInputTokens: number | null = null @@ -2805,7 +2823,7 @@ export function detectSessionOutliers(projects: ProjectSummary[], excludedSessio const outliers: Outlier[] = [] for (const project of projects) { - const sessions = project.sessions.filter(s => s.totalCostUSD > 0) + const sessions = project.sessions.filter(s => isOptimizeSession(s) && s.totalCostUSD > 0) if (sessions.length < MIN_SESSIONS_FOR_OUTLIER) continue const totalCost = sessions.reduce((sum, s) => sum + s.totalCostUSD, 0) @@ -2866,7 +2884,7 @@ function findYoungProjectFirstSessionIds(projects: ProjectSummary[]): Set() for (const project of projects) { - const costed = project.sessions.filter(s => s.totalCostUSD > 0) + const costed = project.sessions.filter(s => isOptimizeSession(s) && s.totalCostUSD > 0) if (costed.length >= YOUNG_PROJECT_SESSION_LIMIT) continue let firstSession: ProjectSummary['sessions'][number] | null = null @@ -2985,15 +3003,25 @@ export function cacheKey(projects: ProjectSummary[], dateRange: DateRange | unde // stale findings when cost/tokens moved (e.g. a re-price) while call count // held - reachable in the long-lived menubar process within the 60s TTL. // Cost is scaled to whole micro-dollars so float jitter cannot thrash the key. - let calls = 0, cost = 0, savings = 0, proxied = 0 + let calls = 0, cost = 0, savings = 0, proxied = 0, sessions = 0, sidechains = 0 + const sidechainIdentities: string[] = [] for (const p of projects) { calls += p.totalApiCalls cost += p.totalCostUSD savings += p.totalSavingsUSD proxied += p.totalProxiedCostUSD + sessions += p.sessions.length + for (const session of p.sessions) { + if (session.isSidechain !== true) continue + sidechains++ + sidechainIdentities.push(`${p.projectPath}\0${session.sessionId}`) + } } + const sidechainDigest = createHash('sha256') + .update(sidechainIdentities.sort().join('\0')) + .digest('base64url') // Costs scaled to whole micro-dollars so float jitter cannot thrash the key. - const fingerprint = `${projects.length}:${calls}:${Math.round(cost * 1e6)}:${Math.round(savings * 1e6)}:${Math.round(proxied * 1e6)}` + const fingerprint = `${projects.length}:${sessions}:${sidechains}:${sidechainDigest}:${calls}:${Math.round(cost * 1e6)}:${Math.round(savings * 1e6)}:${Math.round(proxied * 1e6)}` return `${dr}:${fingerprint}` } @@ -3205,7 +3233,7 @@ function renderOptimize( const issueSuffix = findings.length > 0 ? `, ${findings.length} issue${findings.length > 1 ? 's' : ''}` : '' lines.push(' ' + [ - `${sessionCount} sessions`, + `${sessionCount} session${sessionCount === 1 ? '' : 's'}`, `${callCount.toLocaleString()} calls`, chalk.hex(GOLD)(formatCost(periodCost)), `Health: ${chalk.bold.hex(GRADE_COLORS[healthGrade])(healthGrade)}${chalk.dim(` (${healthScore}/100${issueSuffix})`)}`, @@ -3295,7 +3323,7 @@ export async function runOptimize( const result = await scanAndDetect(projects, dateRange) const { findings, costRate, healthScore, healthGrade } = result - const sessions = projects.flatMap(p => p.sessions) + const sessionCount = optimizeSessionCount(projects) const periodCost = projects.reduce((s, p) => s + p.totalCostUSD, 0) const callCount = projects.reduce((s, p) => s + p.totalApiCalls, 0) @@ -3305,7 +3333,7 @@ export async function runOptimize( } const { topReworkedFiles, coachingNotes } = buildWorkflowReport(projects) - const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount, healthScore, healthGrade, topReworkedFiles, coachingNotes, opts.appliedHeader, opts.previouslyApplied, result.modelRecommendations) + const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessionCount, callCount, healthScore, healthGrade, topReworkedFiles, coachingNotes, opts.appliedHeader, opts.previouslyApplied, result.modelRecommendations) console.log(output) } @@ -3315,7 +3343,6 @@ export function buildOptimizeJsonReport( result: OptimizeResult, dateRange?: DateRange, ): OptimizeJsonReport { - const sessions = projects.flatMap(p => p.sessions) const periodCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0) const calls = projects.reduce((s, p) => s + p.totalApiCalls, 0) const potentialSavingsTokens = result.findings.reduce((s, f) => s + f.tokensSaved, 0) @@ -3335,7 +3362,7 @@ export function buildOptimizeJsonReport( healthGrade: result.healthGrade, findingCount: result.findings.length, periodCostUSD, - sessions: sessions.length, + sessions: optimizeSessionCount(projects), calls, potentialSavingsTokens, potentialSavingsCostUSD, diff --git a/src/parser.ts b/src/parser.ts index 712295b0..47aa9e05 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -2251,6 +2251,7 @@ async function scanProjectDirs( // on a resumed session) and derive the agent id from the `agent-` // filename. A sidechain whose parent id was never captured stays standalone. if (cachedFile.isSidechain) { + session.isSidechain = true if (cachedFile.parentSessionId) session.parentSessionId = cachedFile.parentSessionId session.agentId = sessionId.startsWith('agent-') ? sessionId.slice('agent-'.length) : sessionId } @@ -3327,6 +3328,7 @@ function carryLinkageFields(rebuilt: SessionSummary, original: SessionSummary): if (original.prLinks?.length) rebuilt.prLinks = original.prLinks if (original.prAttributionSource) rebuilt.prAttributionSource = original.prAttributionSource if (original.workingDirectory) rebuilt.workingDirectory = original.workingDirectory + if (original.isSidechain) rebuilt.isSidechain = true // prRefsAtRangeStart is NOT copied here: a narrower slice needs it recomputed at // the new boundary (see recomputeRangeStartPrRefs), not the wide range's value. if (original.parentSessionId) rebuilt.parentSessionId = original.parentSessionId diff --git a/src/session-cache.ts b/src/session-cache.ts index 2759520f..e9b6a163 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -100,7 +100,7 @@ export type CachedFile = { // is re-parsed only when the file changes (fingerprint differs). Carries no // turns, so it contributes no usage. (issue #441 follow-up) failed?: boolean - // Rich-session-capture, Claude session-level (capture-only; no report yet). + // Rich-session-capture, Claude session-level. // `title` is the LAST `ai-title` entry's text; `prLinks` accumulates every // `pr-link` entry's URL. `isSidechain` is true when any entry is a sidechain: // parentUuid references an intra-file entry uuid, not another session id, so it diff --git a/src/types.ts b/src/types.ts index a51ae672..b6b57b5c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -205,6 +205,11 @@ export type SessionSummary = { /// correlations performed after all saved sessions have been parsed. prAttributionSource?: 'transcript' | 'explicit-reference' | 'working-directory' | 'launcher-prompt' source?: SessionSourceMetadata + /// Claude Code only: true when this record is a subagent (sidechain) + /// transcript rather than a user-started parent session. Sidechain spend is + /// real and remains in every cost/token/call aggregate; consumers that reason + /// about human session populations may exclude it explicitly. + isSidechain?: boolean // Claude Code only: agent type of a subagent transcript session // (`workflow-subagent`, `Explore`, `general-purpose`, …); undefined for // ordinary sessions. Drives the Claude-scoped agent-type breakdown. diff --git a/tests/optimize-sidechains.test.ts b/tests/optimize-sidechains.test.ts new file mode 100644 index 00000000..e3d7d039 --- /dev/null +++ b/tests/optimize-sidechains.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('../src/providers/index.js', async (importOriginal) => { + type ProvidersModule = typeof import('../src/providers/index.js') + const actual = await importOriginal() + return { + ...actual, + async discoverAllSessions() { + return [] + }, + } +}) + +import { + buildOptimizeJsonReport, + cacheKey, + computeInputCostRate, + detectSessionOutliers, + findContextBloatCandidates, + findLowWorthCandidates, + runOptimize, + scanAndDetect, + type OptimizeResult, +} from '../src/optimize.js' +import type { ProjectSummary, SessionSummary } from '../src/types.js' + +function session( + sessionId: string, + overrides: Partial = {}, +): SessionSummary { + return { + sessionId, + project: 'app', + firstTimestamp: '2026-08-01T10:00:00.000Z', + lastTimestamp: '2026-08-01T10:30:00.000Z', + totalCostUSD: 1, + totalSavingsUSD: 0, + totalInputTokens: 1_000, + totalOutputTokens: 1_000, + totalReasoningTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as SessionSummary['categoryBreakdown'], + skillBreakdown: {}, + subagentBreakdown: {}, + ...overrides, + } +} + +function sidechain( + sessionId: string, + overrides: Partial = {}, +): SessionSummary { + return session(sessionId, { + isSidechain: true, + parentSessionId: 'parent-session', + agentId: sessionId.replace(/^agent-/, ''), + ...overrides, + }) +} + +function project(sessions: SessionSummary[]): ProjectSummary { + return { + project: 'app', + projectPath: '/tmp/app', + sessions, + totalCostUSD: sessions.reduce((sum, item) => sum + item.totalCostUSD, 0), + totalSavingsUSD: sessions.reduce((sum, item) => sum + item.totalSavingsUSD, 0), + totalApiCalls: sessions.reduce((sum, item) => sum + item.apiCalls, 0), + totalProxiedCostUSD: 0, + } +} + +describe('optimize sidechain population (issue #974)', () => { + it('keeps sidechain spend out of the low-worth candidate population', () => { + const parent = session('parent', { + totalCostUSD: 4, + turns: [], + }) + const child = sidechain('agent-child', { + totalCostUSD: 12, + turns: [], + }) + + expect(findLowWorthCandidates([project([parent, child])]).map(item => item.sessionId)) + .toEqual(['parent']) + }) + + it('does not use a sidechain as a context-heavy candidate or growth baseline', () => { + const baseline = session('parent-baseline', { + firstTimestamp: '2026-08-01T10:00:00.000Z', + totalInputTokens: 20_000, + totalOutputTokens: 2_000, + }) + const child = sidechain('agent-child', { + firstTimestamp: '2026-08-02T10:00:00.000Z', + totalInputTokens: 200_000, + totalOutputTokens: 100, + }) + const candidate = session('parent-candidate', { + firstTimestamp: '2026-08-03T10:00:00.000Z', + totalInputTokens: 100_000, + totalOutputTokens: 2_000, + }) + + const candidates = findContextBloatCandidates([project([baseline, child, candidate])]) + + expect(candidates.map(item => item.sessionId)).toEqual(['parent-candidate']) + expect(candidates[0]!.growthRatio).toBe(5) + }) + + it('does not let a sidechain satisfy the peer-sample minimum for cost outliers', () => { + const sessions = [ + session('parent-cheap', { totalCostUSD: 1 }), + session('parent-expensive', { totalCostUSD: 10 }), + sidechain('agent-cheap', { totalCostUSD: 1 }), + ] + + expect(detectSessionOutliers([project(sessions)])).toBeNull() + }) + + it('never reports an expensive sidechain as a parent-session cost outlier', () => { + const sessions = [ + session('parent-1', { totalCostUSD: 1 }), + session('parent-2', { totalCostUSD: 1 }), + session('parent-3', { totalCostUSD: 1 }), + sidechain('agent-expensive', { totalCostUSD: 100 }), + ] + + expect(detectSessionOutliers([project(sessions)])).toBeNull() + }) + + it('counts only parent sessions while conserving sidechain cost, calls, and tokens', () => { + const projects = [project([ + session('parent', { + totalCostUSD: 3, + totalInputTokens: 100, + totalOutputTokens: 20, + apiCalls: 2, + }), + sidechain('agent-child', { + totalCostUSD: 7, + totalInputTokens: 900, + totalOutputTokens: 80, + apiCalls: 4, + }), + ])] + const result: OptimizeResult = { + findings: [], + costRate: computeInputCostRate(projects), + healthScore: 100, + healthGrade: 'A', + } + + const report = buildOptimizeJsonReport(projects, 'fixture', result) + + expect(report.summary.sessions).toBe(1) + expect(report.summary.periodCostUSD).toBe(10) + expect(report.summary.calls).toBe(6) + // Input-cost calibration keeps all spend and all input/cache tokens: + // ($10 * 0.7) / (100 + 900) tokens. + expect(report.summary.costRateUSD).toBeCloseTo(0.007, 12) + }) + + it('keeps sidechain spend out of every per-session finding in the optimize pipeline', async () => { + const projects = [project([ + sidechain('agent-only', { + totalCostUSD: 100, + totalInputTokens: 1_000_000, + totalOutputTokens: 100, + }), + ])] + + const result = await scanAndDetect(projects, { + start: new Date('2026-08-01T00:00:00.000Z'), + end: new Date('2026-08-02T00:00:00.000Z'), + }) + + expect(result.findings.map(finding => finding.id)).not.toContain('low-worth-sessions') + expect(result.findings.map(finding => finding.id)).not.toContain('context-heavy-sessions') + expect(result.findings.map(finding => finding.id)).not.toContain('cost-outliers') + }) + + it('uses the parent-session count in the text optimize headline', async () => { + const projects = [project([ + session('parent', { + totalCostUSD: 1, + bashBreakdown: { 'git commit -m shipped': { calls: 1 } }, + }), + sidechain('agent-child', { + totalCostUSD: 2, + bashBreakdown: { 'git commit -m irrelevant': { calls: 1 } }, + }), + ])] + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + try { + await runOptimize(projects, 'fixture', { + start: new Date('2026-08-01T00:00:00.000Z'), + end: new Date('2026-08-02T00:00:00.000Z'), + }) + const output = String(log.mock.calls.at(-1)?.[0] ?? '') + expect(output).toContain('1 session') + expect(output).not.toContain('2 sessions') + } finally { + log.mockRestore() + stderr.mockRestore() + } + }) + + it('separates cached optimize results when only sidechain classification changes', () => { + const range = { + start: new Date('2026-08-01T00:00:00.000Z'), + end: new Date('2026-08-02T00:00:00.000Z'), + } + const parentOnly = project([session('same')]) + const sidechainOnly = project([sidechain('same')]) + + expect(parentOnly.totalCostUSD).toBe(sidechainOnly.totalCostUSD) + expect(parentOnly.totalApiCalls).toBe(sidechainOnly.totalApiCalls) + expect(cacheKey([parentOnly], range)).not.toBe(cacheKey([sidechainOnly], range)) + + const firstSidechain = project([session('first'), sidechain('second')]) + const secondSidechain = project([sidechain('first'), session('second')]) + expect(firstSidechain.sessions.length).toBe(secondSidechain.sessions.length) + expect(firstSidechain.totalCostUSD).toBe(secondSidechain.totalCostUSD) + expect(firstSidechain.sessions.filter(item => item.isSidechain).length) + .toBe(secondSidechain.sessions.filter(item => item.isSidechain).length) + expect(cacheKey([firstSidechain], range)).not.toBe(cacheKey([secondSidechain], range)) + }) +}) diff --git a/tests/parser-subagent-range.test.ts b/tests/parser-subagent-range.test.ts index cd50665a..40713fec 100644 --- a/tests/parser-subagent-range.test.ts +++ b/tests/parser-subagent-range.test.ts @@ -4,6 +4,7 @@ import { join } from 'path' import { tmpdir } from 'os' import { parseAllSessions, filterProjectsByDays, clearSessionCache } from '../src/parser.js' +import { clearLoadCacheMemo } from '../src/session-cache.js' import { loadPricing } from '../src/models.js' import { aggregateByPr, prLinkedTotals } from '../src/sessions-report.js' @@ -64,8 +65,16 @@ describe('subagent fold across a date-range boundary', () => { const projects = await parseAllSessions(range, 'claude') // The child session is present (its work is in range) as a standalone session. - const childPresent = projects.some(p => p.sessions.some(s => s.sessionId === `agent-${AGENT}`)) - expect(childPresent).toBe(true) + const child = projects.flatMap(p => p.sessions).find(s => s.sessionId === `agent-${AGENT}`) + expect(child).toBeDefined() + expect(child!.isSidechain).toBe(true) + // Drop both in-process memo layers so the second parse reloads the persisted + // session cache. The marker must survive that warm-disk path too, not only + // the cold transcript parse. + clearSessionCache() + clearLoadCacheMemo() + const warmProjects = await parseAllSessions(range, 'claude') + expect(warmProjects.flatMap(p => p.sessions).find(s => s.sessionId === `agent-${AGENT}`)?.isSidechain).toBe(true) // The anchor parent (0 in-range turns) must NOT contaminate the sessions list; // it lives in subagentAnchors only. const anchorInSessions = projects.some(p => p.sessions.some(s => s.sessionId === PARENT)) @@ -101,6 +110,7 @@ describe('subagent fold across a date-range boundary', () => { const dayFiltered = filterProjectsByDays(projects, new Set(['2026-07-20'])) expect(dayFiltered.some(p => p.sessions.some(s => s.sessionId === PARENT))).toBe(false) // parent no longer a session expect(dayFiltered.some(p => (p.subagentAnchors ?? []).some(s => s.sessionId === PARENT))).toBe(true) // kept as anchor + expect(dayFiltered.flatMap(p => p.sessions).find(s => s.sessionId === `agent-${AGENT}`)?.isSidechain).toBe(true) // The child's spend still folds to the PR through the anchor. const row = aggregateByPr(dayFiltered).find(r => r.url === PR)