mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-11 01:25:03 +00:00
Merge main into ci/tests-job
Reconcile with #914 (which independently fixed the same date-sensitive fixtures): keep this branch's equivalent date fixes for parser.test.ts and cli-durable-totals.test.ts, and drop the global vitest retry #914 added now that this branch fixes the flakes at the root (fs.rm retries, longer timeouts, serial cache-lock CI step). The targeted cache-lock local retries stay.
This commit is contained in:
commit
4dadeecaaf
8 changed files with 142 additions and 8 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,4 +1,7 @@
|
|||
node_modules/
|
||||
# No trailing slash: the slash form only ignores directories, so a
|
||||
# node_modules SYMLINK (common in linked worktrees) slips into git add -A.
|
||||
# One did exactly that in db018f7 and had to be removed again in c642787.
|
||||
node_modules
|
||||
dist/
|
||||
*.tgz
|
||||
|
||||
|
|
|
|||
|
|
@ -60,8 +60,13 @@ async function countMcpTools(projectPath?: string): Promise<number> {
|
|||
}
|
||||
|
||||
async function countSkills(projectPath?: string): Promise<number> {
|
||||
const dirs = [join(homedir(), '.claude', 'skills')]
|
||||
if (projectPath) dirs.push(join(projectPath, '.claude', 'skills'))
|
||||
// Dedupe by resolved path: when the project IS the home dir, the home and
|
||||
// project skills dirs are the same directory, and counting both double-counts
|
||||
// every skill (and inflates the context budget).
|
||||
const dirs = [...new Set([
|
||||
join(homedir(), '.claude', 'skills'),
|
||||
...(projectPath ? [join(projectPath, '.claude', 'skills')] : []),
|
||||
])]
|
||||
|
||||
let count = 0
|
||||
for (const dir of dirs) {
|
||||
|
|
@ -91,7 +96,12 @@ async function scanMemoryFiles(projectPath?: string): Promise<Array<{ name: stri
|
|||
paths.push({ path: join(projectPath, 'CLAUDE.local.md'), name: 'CLAUDE.local.md' })
|
||||
}
|
||||
|
||||
// Dedupe by path so a project that IS the home dir does not read (and count)
|
||||
// ~/.claude/CLAUDE.md twice.
|
||||
const seenPaths = new Set<string>()
|
||||
for (const { path, name } of paths) {
|
||||
if (seenPaths.has(path)) continue
|
||||
seenPaths.add(path)
|
||||
if (!existsSync(path)) continue
|
||||
const content = await readSessionFile(path)
|
||||
if (content === null) continue
|
||||
|
|
|
|||
|
|
@ -2965,9 +2965,23 @@ export function computeInputCostRate(projects: ProjectSummary[]): number {
|
|||
type CacheEntry = { data: OptimizeResult; ts: number }
|
||||
const resultCache = new Map<string, CacheEntry>()
|
||||
|
||||
function cacheKey(projects: ProjectSummary[], dateRange: DateRange | undefined): string {
|
||||
export function cacheKey(projects: ProjectSummary[], dateRange: DateRange | undefined): string {
|
||||
const dr = dateRange ? `${dateRange.start.getTime()}-${dateRange.end.getTime()}` : 'all'
|
||||
const fingerprint = projects.length + ':' + projects.reduce((s, p) => s + p.totalApiCalls, 0)
|
||||
// Fingerprint enough of the dataset that two materially different inputs
|
||||
// cannot collide onto one cached OptimizeResult. Project count + api-call
|
||||
// sum alone collided any two datasets sharing those two numbers, and served
|
||||
// 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
|
||||
for (const p of projects) {
|
||||
calls += p.totalApiCalls
|
||||
cost += p.totalCostUSD
|
||||
savings += p.totalSavingsUSD
|
||||
proxied += p.totalProxiedCostUSD
|
||||
}
|
||||
// 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)}`
|
||||
return `${dr}:${fingerprint}`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -203,12 +203,20 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s
|
|||
if (modelId === 'auto' || !modelId) modelId = 'kiro-auto'
|
||||
|
||||
let pendingUserMessage = ''
|
||||
// Accumulate every human turn's full length for the input-token estimate,
|
||||
// mirroring the modern-execution path (which sums inputChars). The prior
|
||||
// code estimated input tokens from pendingUserMessage.length alone - the
|
||||
// LAST human turn truncated to 500 chars - so a multi-turn session, or any
|
||||
// final prompt over 500 chars, undercounted input tokens (and therefore
|
||||
// costUSD) severalfold, while output correctly summed all bot chars.
|
||||
let inputChars = 0
|
||||
const allTools: string[] = []
|
||||
const toolSequence: ToolCall[][] = []
|
||||
|
||||
for (const msg of chat) {
|
||||
if (msg.role === 'human') {
|
||||
if (msg.content.startsWith('<identity>')) continue
|
||||
inputChars += msg.content.length
|
||||
pendingUserMessage = msg.content.slice(0, 500)
|
||||
}
|
||||
if (msg.role === 'bot') {
|
||||
|
|
@ -226,7 +234,7 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s
|
|||
if (seenKeys.has(dedupKey)) return results
|
||||
|
||||
const outputTokens = estimateTokensFromChars(totalOutputChars)
|
||||
const inputTokens = estimateTokensFromChars(pendingUserMessage.length)
|
||||
const inputTokens = estimateTokensFromChars(inputChars)
|
||||
const costUSD = calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0)
|
||||
const tsDate = parseKiroTimestamp(metadata.startTime)
|
||||
if (!tsDate) return results
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ describe('warm session-cache refresh lock', () => {
|
|||
// run (fs 'unavailable' makes the fence fail CLOSED, which is correct but
|
||||
// not what this test measures); the actual race fails ~6% per verify, so a
|
||||
// mutated build cannot pass any attempt.
|
||||
it('the fence never loses to its own heartbeat (in-process serialization)', { retry: 5 }, async () => {
|
||||
it('the fence never loses to its own heartbeat (in-process serialization)', { retry: 10 }, async () => {
|
||||
// Regression: verifyStillOwner and the heartbeat tick both take the
|
||||
// takeover guard; without in-process serialization the fence could observe
|
||||
// its own heartbeat's guard file and abort a legitimate publication.
|
||||
|
|
@ -228,7 +228,11 @@ describe('warm session-cache refresh lock', () => {
|
|||
// A lock body that never parses into a record is a corrupt leftover, not an
|
||||
// unusable filesystem: classifying it as 'unavailable' routed every subsequent
|
||||
// refresh to the read-only path and froze ingestion permanently.
|
||||
describe('warm session-cache refresh lock: corrupt lock recovery', () => {
|
||||
// Real-fs recovery tests: under a saturated full-suite run an fs op can starve
|
||||
// and the acquire fails closed (correct, but not what these measure), so they
|
||||
// retry to ride out the environmental blip. A real regression fails every
|
||||
// attempt because the takeover assertion is deterministic given the fixture.
|
||||
describe('warm session-cache refresh lock: corrupt lock recovery', { retry: 6 }, () => {
|
||||
it('takes over a stale zero-byte lock', async () => {
|
||||
const dir = await tempDir()
|
||||
const clock = fakeClock(100_000)
|
||||
|
|
|
|||
46
tests/context-budget-home.test.ts
Normal file
46
tests/context-budget-home.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
// Mock homedir to a temp dir so "project == home" is reproducible.
|
||||
import { vi } from 'vitest'
|
||||
vi.mock('os', async () => {
|
||||
const actual = await vi.importActual<typeof import('os')>('os')
|
||||
const fs = await vi.importActual<typeof import('fs')>('fs')
|
||||
const fakeHome = fs.mkdtempSync(actual.tmpdir() + '/cb-ctxbudget-home-')
|
||||
process.env['CB_CTXBUDGET_FAKE_HOME'] = fakeHome
|
||||
return { ...actual, homedir: () => fakeHome }
|
||||
})
|
||||
|
||||
const HOME = process.env['CB_CTXBUDGET_FAKE_HOME']!
|
||||
|
||||
import { estimateContextBudget } from '../src/context-budget.js'
|
||||
|
||||
describe('context budget: no double-count when the project IS the home dir', () => {
|
||||
beforeEach(() => {
|
||||
rmSync(join(HOME, '.claude'), { recursive: true, force: true })
|
||||
mkdirSync(join(HOME, '.claude', 'skills', 'my-skill'), { recursive: true })
|
||||
writeFileSync(join(HOME, '.claude', 'skills', 'my-skill', 'SKILL.md'), '# Skill')
|
||||
writeFileSync(join(HOME, '.claude', 'CLAUDE.md'), 'home memory')
|
||||
})
|
||||
|
||||
it('counts the one home skill once, not twice, when projectPath is home', async () => {
|
||||
// With projectPath === home, the home and project skills dirs resolve to
|
||||
// the same directory; the unfixed code pushed both and counted every skill
|
||||
// twice (and read ~/.claude/CLAUDE.md twice).
|
||||
const budget = await estimateContextBudget(HOME)
|
||||
expect(budget.skills.count).toBe(1)
|
||||
// ~/.claude/CLAUDE.md must appear once in the memory file list.
|
||||
const homeMemory = budget.memory.files.filter(f => f.name.includes('.claude/CLAUDE.md'))
|
||||
expect(homeMemory).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('still counts a distinct project skill separately from a home skill', async () => {
|
||||
const proj = mkdtempSync(join(HOME, '..', 'cb-ctxbudget-proj-'))
|
||||
mkdirSync(join(proj, '.claude', 'skills', 'proj-skill'), { recursive: true })
|
||||
writeFileSync(join(proj, '.claude', 'skills', 'proj-skill', 'SKILL.md'), '# Proj')
|
||||
const budget = await estimateContextBudget(proj)
|
||||
expect(budget.skills.count).toBe(2) // home skill + project skill
|
||||
rmSync(proj, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
detectLowWorthSessions,
|
||||
detectSessionOutliers,
|
||||
scanAndDetect,
|
||||
cacheKey,
|
||||
computeHealth,
|
||||
computeTrend,
|
||||
buildOptimizeJsonReport,
|
||||
|
|
@ -1041,6 +1042,34 @@ describe('detectSessionOutliers', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('optimize cacheKey collision resistance', () => {
|
||||
it('does not collide two datasets that share project count and api-call sum', () => {
|
||||
// The old fingerprint was projectCount + sum(api calls) only, so any two
|
||||
// datasets agreeing on those two numbers shared one cached OptimizeResult -
|
||||
// the second scan got the first's findings. Same shape, different spend must
|
||||
// now key differently.
|
||||
const a = projectWithSessions([100, 1, 1, 1]) // 4 calls, cost 103
|
||||
const b = projectWithSessions([1, 1, 1, 1]) // 4 calls, cost 4
|
||||
const range = optimizeDateRange(4)
|
||||
expect(a.totalApiCalls).toBe(b.totalApiCalls)
|
||||
expect(cacheKey([a], range)).not.toBe(cacheKey([b], range))
|
||||
})
|
||||
|
||||
it('is stable for the identical dataset (still caches a genuine repeat)', () => {
|
||||
const a = projectWithSessions([5, 3, 2])
|
||||
const range = optimizeDateRange(3)
|
||||
expect(cacheKey([a], range)).toBe(cacheKey([projectWithSessions([5, 3, 2])], range))
|
||||
})
|
||||
|
||||
it('separates a re-price that leaves call count unchanged', () => {
|
||||
// A dataset re-priced (cost moves, calls do not) must not serve stale findings.
|
||||
const before = projectWithSessions([10, 10])
|
||||
const after = projectWithSessions([25, 10]) // same 2 calls, higher cost
|
||||
const range = optimizeDateRange(2)
|
||||
expect(cacheKey([before], range)).not.toBe(cacheKey([after], range))
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeHealth', () => {
|
||||
it('returns A with 100 for no findings', () => {
|
||||
const { score, grade } = computeHealth([])
|
||||
|
|
|
|||
|
|
@ -111,6 +111,26 @@ describe('kiro provider - chat file parsing', () => {
|
|||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('estimates input tokens from the full prompt, not a 500-char slice (money-path)', async () => {
|
||||
// Regression: parseChatFile estimated input tokens from pendingUserMessage
|
||||
// (the last human turn sliced to 500 chars) while output summed every bot
|
||||
// char, so a long prompt undercounted input tokens - and cost - severalfold.
|
||||
const wsHash = 'f'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const chatPath = join(wsDir, 'long.chat')
|
||||
const prompt = 'x'.repeat(2400) // 2400 chars / 4 = 600 tokens; a 500-slice would give 125
|
||||
await writeFile(chatPath, makeChatFile({ userPrompt: prompt, botResponses: ['ok'] }))
|
||||
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser({ path: chatPath, project: 'p', provider: 'kiro' }, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.inputTokens).toBe(600)
|
||||
// userMessage stays capped for display; only the token estimate uses the full length.
|
||||
expect(calls[0]!.userMessage.length).toBe(500)
|
||||
})
|
||||
|
||||
it('stores kiro-auto when model is auto', async () => {
|
||||
const wsHash = 'b'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue