mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 06:24:32 +00:00
fix(parser): flatten already-sliced previews and memoize canonical path walks
flatSlice returned strings within the bound unchanged, but provider adapters pre-truncate with .slice(0, 500) before the cache site, so those views still pinned their parent buffers. Always flatten; the round-trip is ~150ns per turn. Use utf16le so lone surrogates survive the copy. Cache the canonical-path Promise instead of the resolved value so calls in one Promise.all batch share a single walk. Document the one-time kiro re-parse and worktree regrouping.
This commit is contained in:
parent
db35fdd079
commit
c7c3a878d8
5 changed files with 40 additions and 13 deletions
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
- **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs.
|
||||
- **Kiro sessions carry the real `projectPath`** (CLI meta.cwd, v2 `workspacePaths[0]`, workspace sessions' `workspaceDirectory`), so git-repo attribution can resolve them; previously they were attribution-blind. Bumps the kiro parse version, so the first run after upgrade re-parses kiro history once, and kiro sessions in linked git worktrees now group under the main repo.
|
||||
|
||||
## 0.9.20 - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ The stores are disjoint (v2 sessions use `sess_`-prefixed IDs in a separate dire
|
|||
- Token counts are estimated via char count (`CHARS_PER_TOKEN = 4`).
|
||||
- **Credits are the cost source; tokens stay estimated.** Kiro bills in credits ($20/mo for 1,000; overage $0.04/credit). CLI (`metering_usage`), v1 executions (`usageSummary[].usage`), and v2 (`usage_summary.promptTurnSummaries[].usage`) turns record real credits, converted to USD at `USD_PER_KIRO_CREDIT = 0.04` (the public overage rate — the same never-understate approach as Codebuff). Turns without credit data fall back to token-estimated cost (`costIsEstimated: true`); legacy `.chat` and workspace-session records carry no usage data, so they are always token-estimated. Note: an earlier CLI implementation summed credit values directly as dollars, overstating cost 25×. Token *counts* remain char-estimated everywhere (input undercounts: only visible transcript text is seen, not the full resent context; v2's `session_metadata.contextUsage.usagePercentage` × context window is a better input proxy if ever needed). v2 does keep the real `modelId`, so unlike the v1 execution-file path it is not mislabeled `kiro-auto`.
|
||||
- **Cost is frozen at parse time.** Kiro is on the `costUSD` pass-through allowlist in `providerCallToCachedCall` (alongside mistral-vibe, devin, hermes, …), so its credit-based cost survives the session cache instead of being re-priced from estimated tokens — token re-pricing understated/overstated real kiro spend by up to 16× per model. The tradeoff, shared with all allowlisted providers: `codeburn price-override` and `model-alias` do not affect kiro dollar amounts (token *counts* are unaffected). Historical caches from before this change re-parse via the `CACHE_VERSION` bump to 5.
|
||||
- **`projectPath` for git attribution.** The parser now records the session's working directory as `projectPath` (CLI `meta.cwd`, v2 `workspacePaths[0]`, workspace sessions' `workspaceDirectory`), which sync attribution needs to resolve the git repo. The `project-path-v1` parse-version bump re-parses cached kiro history once; sessions in linked git worktrees now group under the main repo.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
|
|
|
|||
|
|
@ -38,11 +38,12 @@ export function normalizeContentBlocks<T extends { type?: string; text?: string
|
|||
/// only ~300MB for the same data.
|
||||
///
|
||||
/// Round-tripping through a Buffer forces a fresh flat string with no parent
|
||||
/// reference. Strings already within the bound are returned as-is: they ARE
|
||||
/// the parent, so nothing extra is retained.
|
||||
/// reference. This always runs, even when `s` is already within `max`:
|
||||
/// callers may pass an already-sliced view (provider adapters pre-truncate
|
||||
/// with `.slice(0, 500)` before the cache-site call), and that view is
|
||||
/// itself a SlicedString pinning its own large parent.
|
||||
export function flatSlice(s: string, max: number): string {
|
||||
if (s.length <= max) return s
|
||||
return Buffer.from(s.slice(0, max), 'utf-8').toString('utf-8')
|
||||
return Buffer.from(s.slice(0, max), 'utf16le').toString('utf16le')
|
||||
}
|
||||
|
||||
/// Force a FLAT copy of a string regardless of length.
|
||||
|
|
@ -53,5 +54,5 @@ export function flatSlice(s: string, max: number): string {
|
|||
/// both come back as V8 SlicedStrings. Use this when storing such values
|
||||
/// in long-lived structures; use `flatSlice` when also bounding length.
|
||||
export function flatString(s: string): string {
|
||||
return Buffer.from(s, 'utf-8').toString('utf-8')
|
||||
return Buffer.from(s, 'utf16le').toString('utf16le')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,12 +98,15 @@ function isCoworkSession(cwd: string, filePath: string): boolean {
|
|||
// (measured ~+5% cold-parse time for a large kiro store). Filesystem facts
|
||||
// can go stale in a long-lived process (a dir converted to a worktree
|
||||
// mid-run), so the cache is cleared with the session cache.
|
||||
const canonicalPathCache = new Map<string, { path: string; isWorktree: boolean }>()
|
||||
// Stores the Promise, not the resolved value: callers within the same
|
||||
// Promise.all batch would otherwise all miss the cache and each re-walk the
|
||||
// filesystem before the first walk's result lands.
|
||||
const canonicalPathCache = new Map<string, Promise<{ path: string; isWorktree: boolean }>>()
|
||||
|
||||
async function resolveCanonicalProjectPath(cwd: string): Promise<{ path: string; isWorktree: boolean }> {
|
||||
const cached = canonicalPathCache.get(cwd)
|
||||
if (cached) return cached
|
||||
const result = await resolveCanonicalProjectPathUncached(cwd)
|
||||
const result = resolveCanonicalProjectPathUncached(cwd)
|
||||
canonicalPathCache.set(cwd, result)
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,17 +34,35 @@ describe('flatSlice', () => {
|
|||
expect(out).toBe(s.slice(0, 500))
|
||||
})
|
||||
|
||||
it('documents the mid-surrogate-pair cut behavior (U+FFFD)', () => {
|
||||
it('preserves a lone surrogate at a mid-pair cut', () => {
|
||||
// A cut landing between the high and low surrogate of a pair leaves a
|
||||
// lone surrogate. Plain .slice() preserves it; the Buffer round-trip
|
||||
// replaces it with U+FFFD. Either way the string is length-bounded and
|
||||
// the preceding content is intact — this test pins the chosen behavior
|
||||
// so a future implementation change is a conscious decision.
|
||||
// lone surrogate. utf16le round-trips code units byte-for-byte, so the
|
||||
// lone surrogate survives intact (unlike utf-8, which would replace it
|
||||
// with U+FFFD).
|
||||
const s = 'ab' + '🐾'.repeat(300) // odd offset puts every emoji across even boundaries
|
||||
const out = flatSlice(s, 501) // cuts mid-pair
|
||||
expect(out.length).toBe(501)
|
||||
expect(out.slice(0, 500)).toBe(s.slice(0, 500)) // content before the cut intact
|
||||
expect(out.charCodeAt(500)).toBe(0xfffd) // lone surrogate became U+FFFD
|
||||
expect(out.charCodeAt(500)).toBe(s.charCodeAt(500)) // lone surrogate preserved
|
||||
})
|
||||
|
||||
it('does not retain the parent of an already-sliced view', () => {
|
||||
// The bug this early-return removal fixes: provider adapters pre-truncate
|
||||
// with .slice(0, 500) before the cache-site flatSlice call, so a naive
|
||||
// "already within bound" early return would skip flattening and leave
|
||||
// the SlicedString pinning its 100KB parent.
|
||||
const before = process.memoryUsage().heapUsed
|
||||
const kept: string[] = []
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const parent = (i % 10).toString().repeat(100_000) + i
|
||||
const preSliced = parent.slice(0, 500)
|
||||
kept.push(flatSlice(preSliced, 2000))
|
||||
}
|
||||
if (typeof global.gc === 'function') global.gc()
|
||||
const after = process.memoryUsage().heapUsed
|
||||
const growthMB = (after - before) / 1048576
|
||||
expect(kept.length).toBe(1000)
|
||||
expect(growthMB).toBeLessThan(50)
|
||||
})
|
||||
|
||||
it('does not retain the parent string (heap growth stays bounded)', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue