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:
AgentSeal 2026-08-04 14:09:43 +02:00
commit 4dadeecaaf
8 changed files with 142 additions and 8 deletions

5
.gitignore vendored
View file

@ -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

View file

@ -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

View file

@ -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}`
}

View file

@ -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

View file

@ -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)

View 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 })
})
})

View file

@ -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([])

View file

@ -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)