Files
orca/config/scripts/session-scan-cutoff-benchmark.mjs
T
latteandNeil bb2afe1792 fix(ai-vault): discover and parse Devin sessions on Windows (#21337)
* fix(ai-vault): discover and parse Devin sessions on Windows, restore workspace mapping

Devin sessions never appeared in the AI Vault on Windows, and parsed
nearly empty elsewhere:

- The transcripts root hardcoded the XDG layout
  (~/.local/share/devin/cli/transcripts), but Devin CLI writes under
  %APPDATA%/devin/cli/transcripts on Windows. The root is now
  platform-aware (APPDATA on win32, XDG_DATA_HOME elsewhere) for both
  local scans and win32 remote hosts, and APPDATA joins the scanner
  child's env allowlist so relocated AppData resolves.
- The parser read metadata.is_user_input / created_at / metrics, which
  real ATIF-v1.7 transcripts don't carry. It now also accepts the ATIF
  step shape (source, timestamp, step-level metrics/model_name,
  plain-string message) while keeping the legacy shape.
- ATIF transcripts carry no working_directory, so sessions couldn't
  group under a workspace. The sibling sessions.db index is now merged
  through the existing sidecar seam: it fills cwd/title/model/
  timestamps, honors the db's hidden flag, and re-merges on db-only
  changes without re-reading transcripts.

* fix(ai-vault): inline Devin transcripts root, harden parser/db edge cases

- Resolve the platform-aware Devin cli dir in agent-sources instead of
  importing the shared devin-cli-data-dir module, which is not part of
  this change (broke typecheck).
- Exclude source:'system' steps unconditionally, even when legacy
  metadata fields would classify them as user/assistant messages.
- Guard unix-seconds conversion against out-of-range values so a single
  bad sessions.db row cannot mark the whole index unreadable.

* fix(ai-vault): watch sessions.db-wal so live Devin metadata cannot go stale

In WAL mode, committed rows sit in sessions.db-wal while sessions.db
keeps its stat until checkpoint, so keying the dependency on the db
alone could serve a stale index. The dependency now observes the wal
when one exists; the reader still opens sessions.db itself.

* fix(ai-vault): probe sessions.db-wal through the WSL-gated stat

existsSync bypasses wslGatedStat and can hang a scan on a stalled 9P
mount; the fs-import guard forbids it in session-scanner modules. The
dependency path resolution is now async and probes through the gate.

* fix(ai-vault): honor zero metrics and array messages in Devin steps

- firstDevinMetricValue skipped explicit numeric zeros, letting a
  lower-priority positive metric win and overstating token totals.
- ATIF allows step.message as an array of content parts; route it
  through extractContentText so those steps still feed title/preview.

* test(ai-vault): cover array-valued ATIF message extraction

The extractDevinStepText fallback that routes an array-valued
step.message through extractContentText shipped without a fixture that
produces that shape, so a future refactor could silently drop the
branch. Pin that an array of text parts feeds the step's title and
preview.

* fix(ai-vault): invalidate old Devin caches and bound database retries

* Discover current Devin ATIF exports alongside legacy transcripts

* Recognize drawn geometry in the browser markup contract test

* Deduplicate Devin exports across transcript roots

* Account for the workspace sleep-state reader in scan budget

* Align OMP integration tests with recorded-path resume

* fix: update scan benchmarks and await relay environment test

---------

Co-authored-by: Neil <neil@stably.ai>
2026-09-19 03:32:12 -07:00

198 lines
6.7 KiB
JavaScript

#!/usr/bin/env node
// git show <base>:src/main/ai-vault/session-scanner.ts | node config/scripts/session-scan-cutoff-benchmark.mjs
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { performance } from 'node:perf_hooks'
import ts from 'typescript-api'
import { build } from 'esbuild'
import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs'
const baselineSource = ts.createSourceFile(
'session-scanner.ts',
readFileSync(0, 'utf8'),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS
)
const baselineFunction = baselineSource.statements.find(
(node) => ts.isFunctionDeclaration(node) && node.name?.text === 'canStopParsingSessions'
)
assert(baselineFunction, 'Pipe the baseline session-scanner.ts on stdin')
async function load(contents) {
const result = await build({
stdin: { contents, resolveDir: path.resolve('src/main/ai-vault'), loader: 'ts' },
platform: 'node',
format: 'esm',
bundle: true,
write: false
})
const encoded = Buffer.from(result.outputFiles[0].text).toString('base64')
return import(`data:text/javascript;base64,${encoded}`)
}
const [baselineModule, currentModule] = await Promise.all([
load(`import { sessionSortTime } from './session-scanner-accumulator';
export ${baselineFunction.getText(baselineSource)}`),
load(`export { canStopParsingSessions } from './session-scan-cutoff';
export { ScannedSessionCollection } from './session-root-dedup';`)
])
const baseline = baselineModule.canStopParsingSessions
const current = currentModule.canStopParsingSessions
const { ScannedSessionCollection } = currentModule
let randomState = 91114
function random(bound) {
randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0
return Math.floor((randomState / 2 ** 32) * bound)
}
function session(index, overrides = {}) {
return Object.freeze({
agent: 'claude',
executionHostId: 'local',
sessionId: `session-${index}`,
filePath: `/home/ada/.codex/sessions/rollout-${index}.jsonl`,
codexHome: null,
updatedAt: new Date(index).toISOString(),
modifiedAt: new Date(0).toISOString(),
...overrides
})
}
function collection(rows) {
const result = new ScannedSessionCollection()
for (const row of rows) {
result.add(row)
}
return result
}
function check(sessions, limit, next) {
const rows = [...sessions.values()]
assert.equal(current(sessions, limit, next), baseline(sessions, limit, next))
assert.deepEqual([...sessions.values()], rows)
}
const dates = [
null,
'',
'invalid',
'1970-01-01T00:00:00Z',
'1970-01-01T00:00:02+00:00',
'-000001-01-01T00:00:00Z',
'+010000-01-01T00:00:00Z',
'-271821-04-20T00:00:00.000Z'
]
const limits = [0, -1, -3, 0.5, 1.5, Number.NaN, Infinity, -Infinity]
const nextTimes = [undefined, Number.NaN, Infinity, -Infinity, 0, 1, 2, 2000]
let comparisons = 0
for (let trial = 0; trial < 4_000; trial += 1) {
const sessions = new ScannedSessionCollection()
const admitted = []
for (let batch = 0; batch < 10; batch += 1) {
const count = random(8)
for (let index = 0; index < count; index += 1) {
const id = random(12)
const row =
admitted.length && random(5) === 0
? admitted[random(admitted.length)]
: session(id, {
agent: random(3) ? 'codex' : 'claude',
executionHostId: random(4) ? 'local' : 'ssh:dev',
codexHome: random(2) ? null : '/custom',
updatedAt: random(3)
? new Date(random(5000) - 2500).toISOString()
: dates[random(dates.length)],
modifiedAt: random(4) ? new Date(random(5000)).toISOString() : 'invalid'
})
admitted.push(row)
sessions.add(row)
}
const limit = random(3) ? 1 + random(40) : limits[random(limits.length)]
const next = random(2) ? random(5000) - 2500 : nextTimes[random(nextTimes.length)]
check(sessions, limit, next)
comparisons += 1
}
}
console.log(`${comparisons} differential batch cutoffs passed.`)
function median(values) {
const sorted = values.toSorted((left, right) => left - right)
return (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2
}
function measure(name, run, repeats) {
const expected = run(baseline)
assert.deepEqual(run(current), expected)
const sample = (cutoff) => {
let result
const start = performance.now()
for (let index = 0; index < repeats; index += 1) {
result = run(cutoff)
}
const elapsed = (performance.now() - start) / repeats
assert.deepEqual(result, expected)
return elapsed
}
sample(baseline)
sample(current)
const samples = { baseline: [], current: [] }
for (const pair of buildCounterbalancedSchedule(8, 'baseline', 'current')) {
for (const arm of pair) {
samples[arm].push(sample(arm === 'baseline' ? baseline : current))
}
}
return { name, beforeMs: median(samples.baseline), afterMs: median(samples.current) }
}
console.log(
JSON.stringify({ node: process.version, platform: process.platform, arch: process.arch })
)
const results = []
for (const count of [8, 100, 1_000, 2_000, 10_000]) {
for (const order of ['ordered', 'shuffled']) {
const rows = Array.from({ length: count }, (_, index) => session(count - index))
if (order === 'shuffled') {
for (let index = count - 1; index > 0; index -= 1) {
const other = random(index + 1)
;[rows[index], rows[other]] = [rows[other], rows[index]]
}
}
const sessions = collection(rows)
for (const next of [0, count]) {
results.push(
measure(
`${count} ${order} / ${next === 0 ? 'stop' : 'continue'}`,
(cutoff) => cutoff(sessions, Math.ceil(count / 2), next),
Math.max(20, Math.floor(30_000 / count))
)
)
}
}
}
for (const invalidIndex of [0, 999]) {
const sessions = collection(
Array.from({ length: 1_000 }, (_, index) =>
session(index, invalidIndex === index ? { updatedAt: 'invalid' } : {})
)
)
results.push(measure(`1000 invalid at ${invalidIndex}`, (cutoff) => cutoff(sessions, 500, 0), 50))
}
const rows = Array.from({ length: 2_000 }, () => session(random(2_000)))
results.push(
measure(
'2000-candidate scan cutoff + admission / limit1000',
(cutoff) => {
const sessions = new ScannedSessionCollection()
let index = 0
while (index < rows.length && !cutoff(sessions, 1_000, 10_000)) {
const end = Math.min(rows.length, index + Math.min(8, Math.max(1, 1_000 - sessions.size)))
while (index < end) {
sessions.add(rows[index++])
}
}
return { parsed: index, sessions: sessions.size }
},
2
)
)
console.table(results)
console.log('Synthetic cutoff/admission CPU; excludes discovery, parsing, I/O and final sorting.')