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>
This commit is contained in:
latte
2026-09-19 03:32:12 -07:00
committed by GitHub
co-authored by Neil
parent dcee2c5df3
commit bb2afe1792
34 changed files with 1626 additions and 313 deletions
+4 -1
View File
@@ -82,7 +82,10 @@
}
},
{
"files": ["**/browser-pane/annotate/**"],
"files": [
"**/browser-pane/annotate/**",
"**/browser-pane/ClientHostedBrowserPagePane.markup.test.tsx"
],
"rules": {
"anti-slop/no-shape-in-symbol-names": "off"
}
@@ -5,7 +5,7 @@ import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedu
const bundled = await build({
stdin: {
contents: "export * from './src/main/ai-vault/codex-session-root-dedup.ts'",
contents: "export * from './src/main/ai-vault/session-root-dedup.ts'",
resolveDir: process.cwd(),
loader: 'ts'
},
@@ -22,14 +22,14 @@ function baseline(input) {
const sessions = []
for (let offset = 0; offset < input.length; offset += 8) {
sessions.push(...input.slice(offset, offset + 8))
const unique = production.dedupeCodexSessionsBySessionId(sessions)
const unique = production.dedupeScannedSessions(sessions)
sessions.splice(0, sessions.length, ...unique)
}
return sessions
}
function incremental(input) {
const sessions = new production.CodexSessionCollection()
const sessions = new production.ScannedSessionCollection()
for (let offset = 0; offset < input.length; offset += 8) {
for (const session of input.slice(offset, offset + 8)) {
sessions.add(session)
@@ -63,11 +63,11 @@ function random(max) {
return Math.floor((seed / 0x100000000) * max)
}
if (production.CodexSessionCollection) {
if (production.ScannedSessionCollection) {
let batches = 0
for (let trial = 0; trial < 2000; trial++) {
const input = []
const current = new production.CodexSessionCollection()
const current = new production.ScannedSessionCollection()
let expected = []
for (let batch = 0; batch < 20; batch++) {
const added = Array.from({ length: 1 + random(8) }, () => {
@@ -96,7 +96,7 @@ if (production.CodexSessionCollection) {
})
})
input.push(...added)
expected = production.dedupeCodexSessionsBySessionId([...expected, ...added])
expected = production.dedupeScannedSessions([...expected, ...added])
added.forEach((session) => current.add(session))
checkIdentities([...current.values()], expected)
assert.equal(current.size, expected.length)
@@ -141,7 +141,7 @@ console.log(
)
for (const [name, input] of workloads) {
const expected = baseline(input)
const arms = { baseline, ...(production.CodexSessionCollection ? { incremental } : {}) }
const arms = { baseline, ...(production.ScannedSessionCollection ? { incremental } : {}) }
const repeats = Math.max(1, Math.floor(5000 / input.length))
const samples = { baseline: [], incremental: [] }
for (const run of Object.values(arms)) {
@@ -173,12 +173,12 @@ for (const [name, input] of workloads) {
)
}
if (global.gc && production.CodexSessionCollection) {
if (global.gc && production.ScannedSessionCollection) {
for (const count of [1000, 10000]) {
const input = Array.from({ length: count }, (_, index) => makeSession(index))
global.gc()
const before = process.memoryUsage().heapUsed
const collection = new production.CodexSessionCollection()
const collection = new production.ScannedSessionCollection()
input.forEach((session) => collection.add(session))
global.gc()
const retainedBytes = process.memoryUsage().heapUsed - before
@@ -35,11 +35,11 @@ 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 { CodexSessionCollection } from './codex-session-root-dedup';`)
export { ScannedSessionCollection } from './session-root-dedup';`)
])
const baseline = baselineModule.canStopParsingSessions
const current = currentModule.canStopParsingSessions
const { CodexSessionCollection } = currentModule
const { ScannedSessionCollection } = currentModule
let randomState = 91114
function random(bound) {
@@ -59,7 +59,7 @@ function session(index, overrides = {}) {
})
}
function collection(rows) {
const result = new CodexSessionCollection()
const result = new ScannedSessionCollection()
for (const row of rows) {
result.add(row)
}
@@ -85,7 +85,7 @@ 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 CodexSessionCollection()
const sessions = new ScannedSessionCollection()
const admitted = []
for (let batch = 0; batch < 10; batch += 1) {
const count = random(8)
@@ -180,7 +180,7 @@ results.push(
measure(
'2000-candidate scan cutoff + admission / limit1000',
(cutoff) => {
const sessions = new CodexSessionCollection()
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)))
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { CodexSessionCollection, dedupeCodexSessionsBySessionId } from './codex-session-root-dedup'
import { ScannedSessionCollection, dedupeScannedSessions } from './session-root-dedup'
import { createAccumulator, finalizeSession } from './session-scanner-accumulator'
function session(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
@@ -23,10 +23,10 @@ function session(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
}
function checkBatches(batches: AiVaultSession[][]): AiVaultSession[] {
const collection = new CodexSessionCollection()
const collection = new ScannedSessionCollection()
let expected: AiVaultSession[] = []
for (const batch of batches) {
expected = dedupeCodexSessionsBySessionId([...expected, ...batch])
expected = dedupeScannedSessions([...expected, ...batch])
for (const value of batch) {
collection.add(value)
}
@@ -38,7 +38,7 @@ function checkBatches(batches: AiVaultSession[][]): AiVaultSession[] {
return [...collection.values()]
}
describe('CodexSessionCollection', () => {
describe('ScannedSessionCollection', () => {
it('keeps winner occurrences in input order across replacements and batches', () => {
const other = session({ agent: 'claude' })
const real = session()
@@ -141,7 +141,7 @@ describe('CodexSessionCollection', () => {
it('does not rescan retained rows on admission', () => {
let pathReads = 0
const collection = new CodexSessionCollection()
const collection = new ScannedSessionCollection()
for (let index = 0; index < 1000; index++) {
const value = session({ sessionId: `session-${index}` })
collection.add({
@@ -2,9 +2,9 @@ import { describe, expect, it, vi } from 'vitest'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import {
dedupeCodexRolloutCopyAliases,
dedupeCodexRolloutFileAliases,
dedupeCodexSessionsBySessionId
dedupeCodexRolloutFileAliases
} from './codex-session-root-dedup'
import { dedupeScannedSessions } from './session-root-dedup'
const REAL_HOME_ROLLOUT =
'/Users/ada/.codex/sessions/2026/07/01/rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl'
@@ -305,7 +305,7 @@ describe('dedupeCodexRolloutCopyAliases', () => {
})
})
describe('dedupeCodexSessionsBySessionId', () => {
describe('dedupeScannedSessions', () => {
it('collapses a both-roots session to the real-home row', () => {
const managed = codexSession({
filePath: MANAGED_HOME_ROLLOUT,
@@ -317,8 +317,8 @@ describe('dedupeCodexSessionsBySessionId', () => {
codexHome: null,
id: `local:codex:session-1:${REAL_HOME_ROLLOUT}`
})
expect(dedupeCodexSessionsBySessionId([managed, real])).toEqual([real])
expect(dedupeCodexSessionsBySessionId([real, managed])).toEqual([real])
expect(dedupeScannedSessions([managed, real])).toEqual([real])
expect(dedupeScannedSessions([real, managed])).toEqual([real])
})
it('keeps managed-only and real-only sessions unchanged', () => {
@@ -332,7 +332,7 @@ describe('dedupeCodexSessionsBySessionId', () => {
filePath: REAL_HOME_ROLLOUT,
codexHome: null
})
expect(dedupeCodexSessionsBySessionId([managedOnly, realOnly])).toEqual([managedOnly, realOnly])
expect(dedupeScannedSessions([managedOnly, realOnly])).toEqual([managedOnly, realOnly])
})
it('never collapses across execution hosts or agents', () => {
@@ -352,7 +352,7 @@ describe('dedupeCodexSessionsBySessionId', () => {
agent: 'claude',
filePath: '/home/ada/.codex/sessions/rollout-shared.jsonl'
})
expect(dedupeCodexSessionsBySessionId([local, remote, claude])).toEqual([local, remote, claude])
expect(dedupeScannedSessions([local, remote, claude])).toEqual([local, remote, claude])
})
it('preserves same-host session-id collisions when rollout file names differ', () => {
@@ -370,7 +370,7 @@ describe('dedupeCodexSessionsBySessionId', () => {
updatedAt: '2026-07-02T10:00:00.000Z',
modifiedAt: '2026-07-02T10:00:00.000Z'
})
expect(dedupeCodexSessionsBySessionId([older, newer])).toEqual([older, newer])
expect(dedupeScannedSessions([older, newer])).toEqual([older, newer])
})
it('resolves same-rollout aliases with a stable path tie-break', () => {
@@ -384,7 +384,7 @@ describe('dedupeCodexSessionsBySessionId', () => {
filePath: '/Users/ada/b/.codex/sessions/2026/07/01/rollout-tie.jsonl',
codexHome: null
})
expect(dedupeCodexSessionsBySessionId([tieB, tieA])).toEqual([tieA])
expect(dedupeScannedSessions([tieB, tieA])).toEqual([tieA])
})
it('prefers the managed runtime home over a WSL real home when no host real-home row exists', () => {
@@ -399,7 +399,7 @@ describe('dedupeCodexSessionsBySessionId', () => {
filePath: '\\\\wsl$\\Ubuntu\\home\\ada\\.codex\\sessions\\rollout-a.jsonl',
codexHome: '\\\\wsl$\\Ubuntu\\home\\ada\\.codex'
})
expect(dedupeCodexSessionsBySessionId([wslReal, wslManaged])).toEqual([wslManaged])
expect(dedupeScannedSessions([wslReal, wslManaged])).toEqual([wslManaged])
})
it('never collapses matching host and WSL session identities', () => {
@@ -416,6 +416,6 @@ describe('dedupeCodexSessionsBySessionId', () => {
'\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\orca\\codex-runtime-home\\home'
})
expect(dedupeCodexSessionsBySessionId([host, wsl])).toEqual([host, wsl])
expect(dedupeScannedSessions([host, wsl])).toEqual([host, wsl])
})
})
+2 -122
View File
@@ -223,127 +223,7 @@ export async function dedupeCodexRolloutCopyAliases<T>(
return candidates.filter((candidate) => !aliasesToDrop.has(candidate))
}
/**
* Collapses parsed Codex sessions that share a rollout name and session id on
* one execution host, keeping the canonical root's row. Requiring both the
* parsed id and rollout name preserves id collisions and same-name files whose
* parsed ids differ.
*/
export function dedupeCodexSessionsBySessionId(
sessions: readonly AiVaultSession[]
): AiVaultSession[] {
const bestByKey = new Map<string, AiVaultSession>()
for (const session of sessions) {
const key = codexSessionAliasKey(session)
if (!key) {
continue
}
const best = bestByKey.get(key)
if (!best || codexSessionAliasBeats(session, best)) {
bestByKey.set(key, session)
}
}
return sessions.filter((session) => {
const key = codexSessionAliasKey(session)
if (!key) {
return true
}
return bestByKey.get(key) === session
})
}
type CodexSessionWinner = { session: AiVaultSession; indices: number | number[] }
/** Scan-local accumulation; parsed rows must not be mutated after admission. */
export class CodexSessionCollection {
private readonly sessions = new Map<number, AiVaultSession>()
// Keyed by the row's own sessionId string, so an unlimited scan retains no
// alias key per live row; a per-alias-key map appears only for the rare id
// that spans several hosts, namespaces, or rollout names.
private readonly winnersBySessionId = new Map<
string,
CodexSessionWinner | Map<string, CodexSessionWinner>
>()
private nextIndex = 0
get size(): number {
return this.sessions.size
}
values(): IterableIterator<AiVaultSession> {
return this.sessions.values()
}
add(session: AiVaultSession): void {
const key = codexSessionAliasKey(session)
const index = this.nextIndex++
if (key && !this.admit(session, key, index)) {
return
}
this.sessions.set(index, session)
}
/** Whether the row is retained; a losing alias is dropped. */
private admit(session: AiVaultSession, key: string, index: number): boolean {
const bucket = this.winnersBySessionId.get(session.sessionId)
if (bucket instanceof Map) {
const winner = this.contest(bucket.get(key), session, index)
if (winner) {
bucket.set(key, winner)
}
return winner !== null
}
const bucketKey = bucket && codexSessionAliasKey(bucket.session)
if (bucket && bucketKey && bucketKey !== key) {
this.winnersBySessionId.set(
session.sessionId,
new Map([
[bucketKey, bucket],
[key, { session, indices: index }]
])
)
return true
}
const winner = this.contest(bucket, session, index)
if (winner) {
this.winnersBySessionId.set(session.sessionId, winner)
}
return winner !== null
}
/** The alias key's winner after this row, or null when the row loses. */
private contest(
best: CodexSessionWinner | undefined,
session: AiVaultSession,
index: number
): CodexSessionWinner | null {
if (!best) {
return { session, indices: index }
}
if (best.session === session) {
// The batch filter retains every occurrence of the winning object.
if (typeof best.indices === 'number') {
best.indices = [best.indices, index]
} else {
best.indices.push(index)
}
return best
}
if (!codexSessionAliasBeats(session, best.session)) {
return null
}
if (typeof best.indices === 'number') {
this.sessions.delete(best.indices)
} else {
for (const previousIndex of best.indices) {
this.sessions.delete(previousIndex)
}
}
return { session, indices: index }
}
}
function codexSessionAliasKey(session: AiVaultSession): string | null {
export function codexSessionAliasKey(session: AiVaultSession): string | null {
if (session.agent !== 'codex') {
return null
}
@@ -354,7 +234,7 @@ function codexSessionAliasKey(session: AiVaultSession): string | null {
return `${session.executionHostId}\0${codexPathExecutionNamespace(session.filePath)}\0${session.sessionId}\0${fileName}`
}
function codexSessionAliasBeats(candidate: AiVaultSession, best: AiVaultSession): boolean {
export function codexSessionAliasBeats(candidate: AiVaultSession, best: AiVaultSession): boolean {
const candidateRank = codexSessionRootRank(candidate.codexHome)
const bestRank = codexSessionRootRank(best.codexHome)
if (candidateRank !== bestRank) {
@@ -37,7 +37,7 @@ describe('large remote history through real relay filesystem', () => {
expect(result.sessions.map((session) => session.sessionId)).toEqual(['good'])
expect(result.issues).toEqual([
expect.objectContaining({
path: badPath,
path: badPath.replace(/\\/g, '/'),
message: 'Session transcript record exceeds 10485760 byte limit'
})
])
@@ -128,7 +128,10 @@ describe('large remote history through real relay filesystem', () => {
path = join(home, '.hermes', 'sessions', 'large.json')
record = { session_id: 'large', cwd: '/repo', model: 'test-model', messages }
} else if (agent === 'devin') {
path = join(home, '.local', 'share', 'devin', 'cli', 'transcripts', 'large.json')
path =
platform.os === 'win32'
? join(home, 'AppData', 'Roaming', 'devin', 'cli', 'transcripts', 'large.json')
: join(home, '.local', 'share', 'devin', 'cli', 'transcripts', 'large.json')
record = {
session_id: 'large',
working_directory: '/repo',
@@ -0,0 +1,38 @@
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import { remoteSessionDocumentParsers } from './remote-session-document-parsers'
import type { RemoteSessionSource } from './remote-session-scanner-types'
import { parseDevinSessionContent } from './session-scanner-devin-parser'
// Why: Devin CLI writes transcripts under %APPDATA% on a Windows host and
// ~/.local/share on posix ones.
function remoteDevinDataSegments(hostPlatform: RemoteHostPlatform): string[] {
return hostPlatform.os === 'win32'
? ['AppData', 'Roaming', 'devin', 'cli']
: ['.local', 'share', 'devin', 'cli']
}
export function remoteDevinSource(
remoteHome: string,
hostPlatform: RemoteHostPlatform,
directory: 'transcripts' | 'agent_logs' = 'transcripts'
): RemoteSessionSource {
return {
agent: 'devin',
rootDir: joinRemotePath(
hostPlatform,
remoteHome,
...remoteDevinDataSegments(hostPlatform),
directory
),
extensions: ['.json'],
...remoteSessionDocumentParsers('devin'),
parse: (file, content, context) =>
Promise.resolve(
parseDevinSessionContent(file, content, context.hostPlatform.os, {
executionHostId: context.executionHostId,
executionHostPlatform: context.hostPlatform.os
})
)
}
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { getRemoteHostPlatform, type RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import type { RelayPlatform } from '../ssh/relay-protocol'
import { remoteSessionSources } from './remote-session-scanner-sources'
function devinRootDirs(relayPlatform: RelayPlatform, remoteHome: string): string[] {
const hostPlatform: RemoteHostPlatform = getRemoteHostPlatform(relayPlatform)
return remoteSessionSources(remoteHome, hostPlatform)
.filter((source) => source.agent === 'devin')
.map((source) => source.rootDir)
}
describe('remoteSessionSources devin transcripts root', () => {
it.each([
{
relayPlatform: 'win32-x64' as const,
remoteHome: 'C:/Users/dev',
expected: 'C:/Users/dev/AppData/Roaming/devin/cli/transcripts'
},
{
relayPlatform: 'linux-x64' as const,
remoteHome: '/home/dev',
expected: '/home/dev/.local/share/devin/cli/transcripts'
},
{
relayPlatform: 'darwin-arm64' as const,
remoteHome: '/Users/dev',
expected: '/Users/dev/.local/share/devin/cli/transcripts'
}
])('resolves $expected on $relayPlatform', ({ relayPlatform, remoteHome, expected }) => {
expect(devinRootDirs(relayPlatform, remoteHome)).toEqual([
expected,
expected.replace(/transcripts$/, 'agent_logs')
])
})
})
@@ -6,7 +6,6 @@ import { joinRemotePath } from '../ssh/ssh-remote-platform'
import { parseAntigravitySessionContent } from './session-scanner-antigravity-parser'
import { isAntigravityTranscriptPath } from './session-scanner-antigravity-paths'
import { parseCodexSessionContent } from './session-scanner-codex-parser'
import { parseDevinSessionContent } from './session-scanner-devin-parser'
import { parseDroidSessionContent } from './session-scanner-droid-parser'
import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers'
import { parseClaudeSessionContent } from './session-scanner-primary-parsers'
@@ -20,6 +19,7 @@ import type { FileWithMtime } from './session-scanner-types'
import { normalizeAgentSessionsDir } from './session-scanner-values'
import { remoteCodexIndexedTitleReader } from './remote-session-scanner-codex-index'
import { remoteClineSource } from './remote-session-scanner-cline-source'
import { remoteDevinSource } from './remote-session-scanner-devin-source'
import type {
RemoteParserOptions,
RemoteScannerContext,
@@ -89,14 +89,8 @@ export function remoteSessionSources(
['.json'],
parseHermesSessionContent
),
source(
'devin',
remoteHome,
hostPlatform,
['.local', 'share', 'devin', 'cli', 'transcripts'],
['.json'],
parseDevinSessionContent
),
remoteDevinSource(remoteHome, hostPlatform),
remoteDevinSource(remoteHome, hostPlatform, 'agent_logs'),
jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser),
{
...jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser),
+5 -9
View File
@@ -10,11 +10,10 @@ import type { ExecutionHostId } from '../../shared/execution-host'
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import {
CodexSessionCollection,
codexRolloutHardlinkIdentity,
dedupeCodexRolloutFileAliases,
dedupeCodexSessionsBySessionId
dedupeCodexRolloutFileAliases
} from './codex-session-root-dedup'
import { ScannedSessionCollection, dedupeScannedSessions } from './session-root-dedup'
import {
parseRemoteSessionFileCached,
remoteSessionParseHostKey
@@ -107,7 +106,7 @@ export async function scanRemoteAiVaultSessions(args: {
issues,
limit
})
const parsedSessions = dedupeCodexSessionsBySessionId(parsed.sessions)
const parsedSessions = dedupeScannedSessions(parsed.sessions)
const cappedSessions = parsedSessions
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit)
@@ -123,10 +122,7 @@ export async function scanRemoteAiVaultSessions(args: {
limit,
alreadyParsedFilePaths: parsed.parsedFilePaths
})
const scopeSessions = dedupeCodexSessionsBySessionId([
...parsedScopeSessions,
...extraScopeSessions
])
const scopeSessions = dedupeScannedSessions([...parsedScopeSessions, ...extraScopeSessions])
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit)
@@ -143,7 +139,7 @@ async function parseRemoteSessionCandidates(args: {
issues: AiVaultScanIssue[]
limit: number
}): Promise<{ sessions: AiVaultSession[]; parsedFilePaths: Set<string> }> {
const sessions = new CodexSessionCollection()
const sessions = new ScannedSessionCollection()
const parsedFilePaths = new Set<string>()
let index = 0
@@ -0,0 +1,77 @@
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import {
ensureSessionParseCacheLoaded,
initSessionParseCachePersistence,
resetSessionParseCachePersistenceForTests
} from './session-parse-cache-persistence'
import {
createSessionParseStats,
parseAgentSessionFileCached,
resetSessionParseCacheForTests,
snapshotSessionParseCacheForPersistence
} from './session-scanner-parse-cache'
import type { SessionFileCandidate } from './session-scanner-types'
let root: string | undefined
afterEach(async () => {
resetSessionParseCacheForTests()
resetSessionParseCachePersistenceForTests()
if (root) {
await rm(root, { recursive: true, force: true })
}
})
it('reparses unchanged ATIF transcripts cached before Devin source fields were supported', async () => {
root = await mkdtemp(join(tmpdir(), 'orca-devin-upgrade-'))
const path = join(root, 'devin-session.json')
await writeFile(
path,
JSON.stringify({
session_id: 'devin-session',
steps: [{ source: 'user', message: 'Find the missing sessions' }]
})
)
const fileStat = await stat(path)
const candidate: SessionFileCandidate = {
agent: 'devin',
codexHome: null,
file: {
path,
mtimeMs: fileStat.mtimeMs,
modifiedAt: fileStat.mtime.toISOString(),
sizeBytes: fileStat.size,
sidecar: 'none'
}
}
await parseAgentSessionFileCached(candidate, process.platform)
const entries = snapshotSessionParseCacheForPersistence().map(([filePath, entry]) => [
filePath,
{
...entry,
session: entry.session && {
...entry.session,
title: 'Devin session devin-session',
messageCount: 0,
previewMessages: [],
firstUserPrompt: null,
lastUserPrompt: null
}
}
])
const cacheFile = join(root, 'cache.json')
await writeFile(cacheFile, JSON.stringify({ schemaVersion: 2, appVersion: 'old', entries }))
resetSessionParseCacheForTests()
initSessionParseCachePersistence({ filePath: cacheFile, appVersion: 'new' })
await ensureSessionParseCacheLoaded()
const stats = createSessionParseStats()
const session = await parseAgentSessionFileCached(candidate, process.platform, stats)
expect(stats.fullParses).toBe(1)
expect(stats.reused).toBe(0)
expect(session?.messageCount).toBe(1)
expect(session?.title).toBe('Find the missing sessions')
})
@@ -15,7 +15,7 @@ import type { SessionSidecarObservation } from './session-sidecar-stat'
// Bump when the persisted entry layout or cached session semantics change; a
// mismatched file is discarded whole.
const SCHEMA_VERSION = 2
const SCHEMA_VERSION = 3
// Debounce so back-to-back scans (desktop IPC + runtime RPC) collapse into one write.
const SAVE_DEBOUNCE_MS = 1_500
// The payload contains transcript-derived preview text; keep it user-only
+146
View File
@@ -0,0 +1,146 @@
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { codexSessionAliasKey, codexSessionAliasBeats } from './codex-session-root-dedup'
import { sessionSortTime } from './session-scanner-accumulator'
function sessionAliasKey(session: AiVaultSession): string | null {
if (session.agent !== 'devin') {
const codexKey = codexSessionAliasKey(session)
return codexKey ? `codex\0${codexKey}` : null
}
// Sibling exports share an index; different installs and WSL distros do not.
const cliDir = session.filePath.split(/[\\/]/).slice(0, -2).join('/')
return `devin\0${session.executionHostId}\0${cliDir}\0${session.sessionId}`
}
function sessionAliasBeats(candidate: AiVaultSession, best: AiVaultSession): boolean {
if (candidate.agent !== 'devin') {
return codexSessionAliasBeats(candidate, best)
}
const candidateTime = sessionSortTime(candidate)
const bestTime = sessionSortTime(best)
if (candidateTime !== bestTime) {
return candidateTime > bestTime
}
// The database can give both exports the same activity time.
if (candidate.modifiedAt !== best.modifiedAt) {
return Date.parse(candidate.modifiedAt) > Date.parse(best.modifiedAt)
}
const candidateCurrent = candidate.filePath.split(/[\\/]/).at(-2) === 'agent_logs'
const bestCurrent = best.filePath.split(/[\\/]/).at(-2) === 'agent_logs'
if (candidateCurrent !== bestCurrent) {
return candidateCurrent
}
return candidate.filePath < best.filePath
}
export function dedupeScannedSessions(sessions: readonly AiVaultSession[]): AiVaultSession[] {
const bestByKey = new Map<string, AiVaultSession>()
for (const session of sessions) {
const key = sessionAliasKey(session)
if (!key) {
continue
}
const best = bestByKey.get(key)
if (!best || sessionAliasBeats(session, best)) {
bestByKey.set(key, session)
}
}
return sessions.filter((session) => {
const key = sessionAliasKey(session)
if (!key) {
return true
}
return bestByKey.get(key) === session
})
}
type SessionWinner = { session: AiVaultSession; indices: number | number[] }
/** Scan-local accumulation; parsed rows must not be mutated after admission. */
export class ScannedSessionCollection {
private readonly sessions = new Map<number, AiVaultSession>()
// Keyed by the row's own sessionId string, so an unlimited scan retains no
// alias key per live row; a per-alias-key map appears only for the rare id
// that spans several hosts, namespaces, or rollout names.
private readonly winnersBySessionId = new Map<
string,
SessionWinner | Map<string, SessionWinner>
>()
private nextIndex = 0
get size(): number {
return this.sessions.size
}
values(): IterableIterator<AiVaultSession> {
return this.sessions.values()
}
add(session: AiVaultSession): void {
const key = sessionAliasKey(session)
const index = this.nextIndex++
if (key && !this.admit(session, key, index)) {
return
}
this.sessions.set(index, session)
}
/** Whether the row is retained; a losing alias is dropped. */
private admit(session: AiVaultSession, key: string, index: number): boolean {
const bucket = this.winnersBySessionId.get(session.sessionId)
if (bucket instanceof Map) {
const winner = this.contest(bucket.get(key), session, index)
if (winner) {
bucket.set(key, winner)
}
return winner !== null
}
const bucketKey = bucket && sessionAliasKey(bucket.session)
if (bucket && bucketKey && bucketKey !== key) {
this.winnersBySessionId.set(
session.sessionId,
new Map([
[bucketKey, bucket],
[key, { session, indices: index }]
])
)
return true
}
const winner = this.contest(bucket, session, index)
if (winner) {
this.winnersBySessionId.set(session.sessionId, winner)
}
return winner !== null
}
/** The alias key's winner after this row, or null when the row loses. */
private contest(
best: SessionWinner | undefined,
session: AiVaultSession,
index: number
): SessionWinner | null {
if (!best) {
return { session, indices: index }
}
if (best.session === session) {
// The batch filter retains every occurrence of the winning object.
if (typeof best.indices === 'number') {
best.indices = [best.indices, index]
} else {
best.indices.push(index)
}
return best
}
if (!sessionAliasBeats(session, best.session)) {
return null
}
if (typeof best.indices === 'number') {
this.sessions.delete(best.indices)
} else {
for (const previousIndex of best.indices) {
this.sessions.delete(previousIndex)
}
}
return { session, indices: index }
}
}
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { CodexSessionCollection } from './codex-session-root-dedup'
import { ScannedSessionCollection } from './session-root-dedup'
import { canStopParsingSessions } from './session-scan-cutoff'
import { createAccumulator, finalizeSession, sessionSortTime } from './session-scanner-accumulator'
@@ -23,8 +23,8 @@ function session(time: number | string, overrides: Partial<AiVaultSession> = {})
})
}
function collection(rows: AiVaultSession[]): CodexSessionCollection {
const result = new CodexSessionCollection()
function collection(rows: AiVaultSession[]): ScannedSessionCollection {
const result = new ScannedSessionCollection()
rows.forEach((row) => result.add(row))
return result
}
+2 -2
View File
@@ -1,7 +1,7 @@
import type { CodexSessionCollection } from './codex-session-root-dedup'
import type { ScannedSessionCollection } from './session-root-dedup'
import { sessionSortTime } from './session-scanner-accumulator'
type ScanSessions = Pick<CodexSessionCollection, 'size' | 'values'>
type ScanSessions = Pick<ScannedSessionCollection, 'size' | 'values'>
function sortedCutoffIsNewer(
times: number[],
@@ -53,7 +53,23 @@ const CASES = [
envVar: 'DEVIN_HOME',
absolute: '/srv/devin',
absoluteRoot: join('/srv/devin', 'transcripts'),
defaultRoot: () => join(homedir(), '.local', 'share', 'devin', 'cli', 'transcripts')
// Mirrors the platform-aware default in session-scanner-agent-sources.ts:
// %APPDATA%\devin\cli on Windows, $XDG_DATA_HOME/devin/cli elsewhere.
defaultRoot: () =>
join(
process.platform === 'win32'
? join(
process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming'),
'devin',
'cli'
)
: join(
process.env.XDG_DATA_HOME?.trim() || join(homedir(), '.local', 'share'),
'devin',
'cli'
),
'transcripts'
)
},
{
agent: 'openclaw',
@@ -84,10 +100,51 @@ describe('agent scan roots from environment overrides', () => {
vi.resetModules()
})
it('finds Devin transcripts in a relocated platform data directory', async () => {
const dataDir = join(homedir(), 'relocated-agent-data')
const roots = await rootDirsFor('devin', {
DEVIN_HOME: '',
[process.platform === 'win32' ? 'APPDATA' : 'XDG_DATA_HOME']: dataDir
})
expect(roots).toEqual(
['transcripts', 'agent_logs'].map((dir) => join(dataDir, 'devin', 'cli', dir))
)
})
it('finds Devin transcripts when the platform data variable is empty', async () => {
const roots = await rootDirsFor('devin', {
DEVIN_HOME: '',
APPDATA: '',
XDG_DATA_HOME: ''
})
const dataDir =
process.platform === 'win32'
? join(homedir(), 'AppData', 'Roaming')
: join(homedir(), '.local', 'share')
expect(roots).toEqual(
['transcripts', 'agent_logs'].map((dir) => join(dataDir, 'devin', 'cli', dir))
)
})
it('keeps an explicit transcript root isolated and discovers both WSL layouts', async () => {
const { AI_VAULT_AGENT_SOURCES } = await import('./session-scanner-agent-sources')
const custom = join(homedir(), 'custom-devin')
const wslHome = join(homedir(), 'wsl-home')
expect(
AI_VAULT_AGENT_SOURCES.devin?.rootDirs({ devinTranscriptsDir: custom }, [wslHome])
).toEqual([
custom,
join(wslHome, '.local', 'share', 'devin', 'cli', 'transcripts'),
join(wslHome, '.local', 'share', 'devin', 'cli', 'agent_logs')
])
})
for (const testCase of CASES) {
describe(testCase.envVar, () => {
it('uses an absolute override', async () => {
const roots = await rootDirsFor(testCase.agent, { [testCase.envVar]: testCase.absolute })
const roots = await rootDirsFor(testCase.agent, {
[testCase.envVar]: testCase.absolute
})
expect(roots[0]).toBe(testCase.absoluteRoot)
})
@@ -99,14 +156,18 @@ describe('agent scan roots from environment overrides', () => {
})
it.each(RELATIVE_VALUES)('falls back to the default root for %j', async (value) => {
const roots = await rootDirsFor(testCase.agent, { [testCase.envVar]: value })
const roots = await rootDirsFor(testCase.agent, {
[testCase.envVar]: value
})
expect(roots[0]).toBe(testCase.defaultRoot())
})
// A relative root is the actual #13082 failure: it resolves against whichever Orca process
// reads it, so the walk starts somewhere arbitrary and has no depth, entry or time cap.
it.each(RELATIVE_VALUES)('never yields a relative root for %j', async (value) => {
const roots = await rootDirsFor(testCase.agent, { [testCase.envVar]: value })
const roots = await rootDirsFor(testCase.agent, {
[testCase.envVar]: value
})
for (const root of roots) {
expect(root).toBe(join(root))
expect(root.startsWith('/') || /^[A-Za-z]:[\\/]/.test(root)).toBe(true)
@@ -10,6 +10,7 @@ import {
isClineSessionMetadataPath
} from './session-scanner-cline-parser'
import { cursorChatMetaPath } from './session-scanner-cursor-chat-meta'
import { devinSessionsDbDependencyPath } from './session-scanner-devin-db'
import { resolveKimiSessionsDir } from './session-scanner-kimi-paths'
import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts'
import {
@@ -46,11 +47,18 @@ const PI_SESSIONS_DIR = normalizeAgentSessionsDir(
// dedicated sessions-root override, so resolution differs from Pi/OMP in shape
// as well as in variable name.
const PRIME_AGENT_SESSIONS_DIR = primeAgentSessionsDirFromEnv()
// Why: Devin ATIF transcripts are stored under <DEVIN_HOME>/transcripts.
// Why: Devin ATIF transcripts live under <DEVIN_HOME>/transcripts; the cli
// data dir is %APPDATA%\devin\cli on Windows, $XDG_DATA_HOME/devin/cli elsewhere.
const DEVIN_TRANSCRIPTS_DIR = join(
resolveAbsoluteDirOverride(
process.env.DEVIN_HOME,
join(homedir(), '.local', 'share', 'devin', 'cli')
process.platform === 'win32'
? join(process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming'), 'devin', 'cli')
: join(
process.env.XDG_DATA_HOME?.trim() || join(homedir(), '.local', 'share'),
'devin',
'cli'
)
),
'transcripts'
)
@@ -94,7 +102,10 @@ type AiVaultAgentSourceTable = Record<AiVaultDeletableAgent, AiVaultAgentSource>
export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = {
claude: {
rootDirs: (options, wslHomeDirs) =>
claudeProjectsRootDirs({ claudeProjectsDir: options.claudeProjectsDir, wslHomeDirs }),
claudeProjectsRootDirs({
claudeProjectsDir: options.claudeProjectsDir,
wslHomeDirs
}),
extensions: ['.jsonl'],
// Why: Task subagent transcripts under `<session>/subagents/` share the parent
// sessionId and aren't independently resumable, so they'd just duplicate the
@@ -152,15 +163,26 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = {
filePredicate: (filePath) => basename(filePath) === 'summary.json'
},
devin: {
rootDirs: (options, wslHomeDirs) =>
sessionRootDirs(options.devinTranscriptsDir ?? DEVIN_TRANSCRIPTS_DIR, wslHomeDirs, [
rootDirs: (options, wslHomeDirs) => [
...sessionRootDirs(options.devinTranscriptsDir ?? DEVIN_TRANSCRIPTS_DIR, wslHomeDirs, [
'.local',
'share',
'devin',
'cli',
'transcripts'
]),
extensions: ['.json']
// Devin 3000.10.31 exports ATIF to agent_logs by default.
...(options.devinTranscriptsDir ? [] : [join(dirname(DEVIN_TRANSCRIPTS_DIR), 'agent_logs')]),
...wslHomeDirs.map((homeDir) =>
join(homeDir, '.local', 'share', 'devin', 'cli', 'agent_logs')
)
],
mergeRootDiscoveries: true,
extensions: ['.json'],
// Why: one sessions.db indexes the whole transcripts dir from beside it;
// tracking its stat lets a db-only change (title edit, hide) re-merge
// sessions without re-reading any transcript.
contentDependencyPath: devinSessionsDbDependencyPath
},
hermes: {
rootDirs: (options, wslHomeDirs) =>
@@ -0,0 +1,55 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import SyncDatabase from '../sqlite/sync-database'
import { scanAiVaultSessions } from './session-scanner'
import { resetDevinSessionsIndexCacheForTests } from './session-scanner-devin-db'
import * as databaseReader from './session-scanner-opencode-sqlite-open'
import { resetSessionParseCacheForTests } from './session-scanner-parse-cache'
import { isolatedScanRoots } from './session-scanner-test-fixtures'
let root: string | undefined
afterEach(async () => {
vi.restoreAllMocks()
resetSessionParseCacheForTests()
resetDevinSessionsIndexCacheForTests()
if (root) {
await rm(root, { recursive: true, force: true })
}
})
it('attempts a contended index once per scan and enriches all transcripts after recovery', async () => {
root = await mkdtemp(join(tmpdir(), 'orca-devin-contention-'))
const roots = isolatedScanRoots(root)
await mkdir(roots.devinTranscriptsDir, { recursive: true })
const db = new SyncDatabase(join(root, 'sessions.db'))
const cwd = join(root, 'workspace')
try {
db.exec('CREATE TABLE sessions (id TEXT PRIMARY KEY, working_directory TEXT)')
for (const id of ['one', 'two', 'three']) {
db.prepare('INSERT INTO sessions VALUES (?, ?)').run(id, cwd)
await writeFile(
join(roots.devinTranscriptsDir, `${id}.json`),
JSON.stringify({ session_id: id, steps: [{ source: 'user', message: `Task ${id}` }] })
)
}
} finally {
db.close()
}
const reader = vi.spyOn(databaseReader, 'readOpenCodeDatabase').mockImplementation(() => {
throw new Error('SQLITE_BUSY')
})
const first = await scanAiVaultSessions({ ...roots, unlimited: true })
expect(first.sessions).toHaveLength(3)
expect(first.sessions.every((session) => session.cwd === null)).toBe(true)
expect(reader).toHaveBeenCalledTimes(1)
reader.mockRestore()
const recoveredReader = vi.spyOn(databaseReader, 'readOpenCodeDatabase')
const recovered = await scanAiVaultSessions({ ...roots, unlimited: true })
expect(recovered.sessions).toHaveLength(3)
expect(recovered.sessions.every((session) => session.cwd === cwd)).toBe(true)
expect(recoveredReader).toHaveBeenCalledTimes(1)
})
@@ -0,0 +1,468 @@
import { mkdir, mkdtemp, rm, stat, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import SyncDatabase from '../sqlite/sync-database'
import { scanAiVaultSessions } from './session-scanner'
import {
devinSessionsDbDependencyPath,
devinSessionsDbPath,
devinSessionsIndexForSidecar,
resetDevinSessionsIndexCacheForTests
} from './session-scanner-devin-db'
import { parseDevinSessionContent } from './session-scanner-devin-parser'
import { enrichSessionFromSidecar } from './session-scanner-sidecar-enrichment'
import { isolatedScanRoots } from './session-scanner-test-fixtures'
import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types'
import type { SessionSidecarStat } from './session-sidecar-stat'
// The Devin CLI sessions.db schema (3000.10.x). Written out in full rather
// than trimmed, because the reader probes every column it names.
const DEVIN_SESSIONS_SCHEMA = `
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
working_directory TEXT,
backend_type TEXT,
model TEXT,
agent_mode TEXT,
created_at INTEGER,
last_activity_at INTEGER,
title TEXT,
main_chain_id TEXT,
shell_last_seen_index INTEGER,
cogs_json TEXT,
workspace_dirs TEXT,
hidden INTEGER,
metadata TEXT
);
`
const DEVIN_CREATED_S = 1_777_000_000
const DEVIN_ACTIVITY_S = 1_777_003_600
let tempDirs: string[] = []
afterEach(async () => {
resetDevinSessionsIndexCacheForTests()
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
tempDirs = []
})
async function tempDir(prefix: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), prefix))
tempDirs.push(dir)
return dir
}
type DevinDbRow = {
id: string
working_directory?: string | null
model?: string | null
title?: string | null
created_at?: number | null
last_activity_at?: number | null
hidden?: number | null
}
function writeDevinSessionsDb(dbPath: string, rows: readonly DevinDbRow[]): void {
const db = new SyncDatabase(dbPath)
try {
db.exec(DEVIN_SESSIONS_SCHEMA)
db.exec('DELETE FROM sessions')
const insert = db.prepare(
`INSERT INTO sessions (id, working_directory, model, title, created_at, last_activity_at, hidden)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
for (const row of rows) {
insert.run(
row.id,
row.working_directory ?? null,
row.model ?? null,
row.title ?? null,
row.created_at ?? null,
row.last_activity_at ?? null,
row.hidden ?? 0
)
}
} finally {
db.close()
}
}
async function sidecarOf(dbPath: string): Promise<SessionSidecarStat> {
const fileStat = await stat(dbPath)
return { path: dbPath, mtimeMs: fileStat.mtimeMs, sizeBytes: fileStat.size }
}
function devinCandidate(filePath: string, sidecar: FileWithMtime['sidecar']): SessionFileCandidate {
return {
agent: 'devin',
file: {
path: filePath,
mtimeMs: 1,
modifiedAt: new Date(1).toISOString(),
sidecar
},
codexHome: null
}
}
function devinFoldSession(filePath: string, record: Record<string, unknown>) {
return parseDevinSessionContent(
{ path: filePath, mtimeMs: 1, modifiedAt: new Date(1).toISOString() },
JSON.stringify(record),
'linux'
)
}
describe('devinSessionsDbPath', () => {
it('names the sessions.db beside the transcripts dir', () => {
expect(devinSessionsDbPath(join('cli', 'transcripts', 'apricot-houseboat.json'))).toBe(
join('cli', 'sessions.db')
)
})
})
describe('devinSessionsDbDependencyPath', () => {
it('points at sessions.db until a wal file appears beside it', async () => {
const dir = await tempDir('orca-devin-db-')
const cliDir = join(dir, 'cli')
const transcriptPath = join(cliDir, 'transcripts', 'apricot.json')
const dbPath = join(cliDir, 'sessions.db')
expect(await devinSessionsDbDependencyPath(transcriptPath)).toBe(dbPath)
await mkdir(cliDir, { recursive: true })
await writeFile(`${dbPath}-wal`, 'wal bytes')
expect(await devinSessionsDbDependencyPath(transcriptPath)).toBe(`${dbPath}-wal`)
})
})
describe('devinSessionsIndexForSidecar', () => {
it('reads rows keyed by session id with unix seconds as ISO strings', async () => {
const dir = await tempDir('orca-devin-db-')
const dbPath = join(dir, 'sessions.db')
writeDevinSessionsDb(dbPath, [
{
id: 'apricot-houseboat',
working_directory: 'D:\\work\\orca',
model: 'swe-1-6-fast',
title: 'Fix the vault',
created_at: DEVIN_CREATED_S,
last_activity_at: DEVIN_ACTIVITY_S,
hidden: 0
},
{ id: 'hidden-one', hidden: 1 }
])
const { index, unreadable } = devinSessionsIndexForSidecar(await sidecarOf(dbPath))
expect(unreadable).toBe(false)
const row = index?.get('apricot-houseboat')
expect(row).toEqual({
workingDirectory: 'D:\\work\\orca',
model: 'swe-1-6-fast',
title: 'Fix the vault',
createdAt: new Date(DEVIN_CREATED_S * 1000).toISOString(),
lastActivityAt: new Date(DEVIN_ACTIVITY_S * 1000).toISOString(),
hidden: false
})
expect(index?.get('hidden-one')?.hidden).toBe(true)
})
it('returns no index when discovery observed no db', () => {
expect(devinSessionsIndexForSidecar('none').index).toBeNull()
expect(devinSessionsIndexForSidecar(undefined).index).toBeNull()
})
it('marks a stat-refused db unreadable without opening it', () => {
const { index, unreadable } = devinSessionsIndexForSidecar('unknown')
expect(index).toBeNull()
expect(unreadable).toBe(true)
})
it('yields an empty index when the sessions table is absent', async () => {
const dir = await tempDir('orca-devin-db-')
const dbPath = join(dir, 'sessions.db')
const db = new SyncDatabase(dbPath)
try {
db.exec('CREATE TABLE other (id TEXT)')
} finally {
db.close()
}
const { index, unreadable } = devinSessionsIndexForSidecar(await sidecarOf(dbPath))
expect(unreadable).toBe(false)
expect(index?.size).toBe(0)
})
it('tolerates an older schema missing optional columns', async () => {
const dir = await tempDir('orca-devin-db-')
const dbPath = join(dir, 'sessions.db')
const db = new SyncDatabase(dbPath)
try {
db.exec('CREATE TABLE sessions (id TEXT PRIMARY KEY, working_directory TEXT)')
db.prepare('INSERT INTO sessions (id, working_directory) VALUES (?, ?)').run(
'old-session',
'/srv/old'
)
} finally {
db.close()
}
const { index, unreadable } = devinSessionsIndexForSidecar(await sidecarOf(dbPath))
expect(unreadable).toBe(false)
expect(index?.get('old-session')).toEqual({
workingDirectory: '/srv/old',
model: null,
title: null,
createdAt: null,
lastActivityAt: null,
hidden: false
})
})
it('reports a corrupt db as unreadable rather than throwing', async () => {
const dir = await tempDir('orca-devin-db-')
const dbPath = join(dir, 'sessions.db')
await writeFile(dbPath, 'this is not sqlite')
const { index, unreadable } = devinSessionsIndexForSidecar(await sidecarOf(dbPath))
expect(index).toBeNull()
expect(unreadable).toBe(true)
})
it('invalidates when the observed file changes from db to wal with identical stats', async () => {
const dir = await tempDir('orca-devin-db-')
const dbPath = join(dir, 'sessions.db')
writeDevinSessionsDb(dbPath, [{ id: 'apricot', title: 'old' }])
const observation = await sidecarOf(dbPath)
expect(devinSessionsIndexForSidecar(observation).index?.get('apricot')?.title).toBe('old')
writeDevinSessionsDb(dbPath, [{ id: 'apricot', title: 'new' }])
const updated = devinSessionsIndexForSidecar({ ...observation, path: `${dbPath}-wal` })
expect(updated.index?.get('apricot')?.title).toBe('new')
})
it('opens the db for a wal observation and re-reads when the wal stat moves', async () => {
const dir = await tempDir('orca-devin-db-')
const dbPath = join(dir, 'sessions.db')
const walPath = `${dbPath}-wal`
writeDevinSessionsDb(dbPath, [{ id: 'apricot', title: 'old' }])
const dbStatBefore = await stat(dbPath)
await writeFile(walPath, 'wal-v1')
const first = devinSessionsIndexForSidecar(await sidecarOf(walPath))
expect(first.unreadable).toBe(false)
expect(first.index?.get('apricot')?.title).toBe('old')
// A wal-mode write can leave the db stat untouched; restore it so only
// the wal observation differs between reads.
writeDevinSessionsDb(dbPath, [{ id: 'apricot', title: 'new' }])
await utimes(dbPath, dbStatBefore.atimeMs / 1000, dbStatBefore.mtimeMs / 1000)
await writeFile(walPath, 'wal-v2-longer')
const second = devinSessionsIndexForSidecar(await sidecarOf(walPath))
expect(second.unreadable).toBe(false)
expect(second.index?.get('apricot')?.title).toBe('new')
})
})
describe('enrichSessionFromSidecar for devin', () => {
it('fills cwd, generated title, model and timestamps from the db row', async () => {
const dir = await tempDir('orca-devin-db-')
const cliDir = join(dir, 'cli')
const transcriptsDir = join(cliDir, 'transcripts')
await mkdir(transcriptsDir, { recursive: true })
const filePath = join(transcriptsDir, 'apricot.json')
const dbPath = join(cliDir, 'sessions.db')
writeDevinSessionsDb(dbPath, [
{
id: 'apricot',
working_directory: '/srv/work',
model: 'swe-1-6-fast',
title: 'Db title',
created_at: DEVIN_CREATED_S,
last_activity_at: DEVIN_ACTIVITY_S
}
])
const fold = devinFoldSession(filePath, {
session_id: 'apricot',
steps: []
})
expect(fold?.cwd).toBeNull()
const { session, refused } = await enrichSessionFromSidecar(
devinCandidate(filePath, await sidecarOf(dbPath)),
fold,
'linux'
)
expect(refused).toBe(false)
expect(session?.cwd).toBe('/srv/work')
expect(session?.title).toBe('Db title')
expect(session?.model).toBe('swe-1-6-fast')
expect(session?.createdAt).toBe(new Date(DEVIN_CREATED_S * 1000).toISOString())
expect(session?.updatedAt).toBe(new Date(DEVIN_ACTIVITY_S * 1000).toISOString())
expect(session?.resumeCommand).toContain('/srv/work')
})
it('keeps transcript-derived fields over the db row', async () => {
const dir = await tempDir('orca-devin-db-')
const cliDir = join(dir, 'cli')
const transcriptsDir = join(cliDir, 'transcripts')
await mkdir(transcriptsDir, { recursive: true })
const filePath = join(transcriptsDir, 'apricot.json')
const dbPath = join(cliDir, 'sessions.db')
writeDevinSessionsDb(dbPath, [
{
id: 'apricot',
working_directory: '/srv/db-cwd',
model: 'db-model',
title: 'Db title',
created_at: DEVIN_CREATED_S
}
])
const fold = devinFoldSession(filePath, {
session_id: 'apricot',
working_directory: '/srv/transcript-cwd',
agent: { model_name: 'transcript-model' },
steps: [
{
metadata: {
created_at: '2026-05-01T10:00:00.000Z',
is_user_input: true
},
text: 'Transcript title'
}
]
})
const { session } = await enrichSessionFromSidecar(
devinCandidate(filePath, await sidecarOf(dbPath)),
fold,
'linux'
)
expect(session?.cwd).toBe('/srv/transcript-cwd')
expect(session?.title).toBe('Transcript title')
expect(session?.model).toBe('transcript-model')
expect(session?.createdAt).toBe('2026-05-01T10:00:00.000Z')
})
it('drops a session the user hid in Devin', async () => {
const dir = await tempDir('orca-devin-db-')
const cliDir = join(dir, 'cli')
const transcriptsDir = join(cliDir, 'transcripts')
await mkdir(transcriptsDir, { recursive: true })
const filePath = join(transcriptsDir, 'apricot.json')
const dbPath = join(cliDir, 'sessions.db')
writeDevinSessionsDb(dbPath, [{ id: 'apricot', hidden: 1 }])
const fold = devinFoldSession(filePath, {
session_id: 'apricot',
steps: []
})
const { session, refused } = await enrichSessionFromSidecar(
devinCandidate(filePath, await sidecarOf(dbPath)),
fold,
'linux'
)
expect(session).toBeNull()
expect(refused).toBe(false)
})
})
describe('devin sessions.db through the scan', () => {
async function writeDevinVault(
dir: string,
directory = 'transcripts'
): Promise<{ transcriptsDir: string; dbPath: string }> {
const cliDir = join(dir, 'devin-cli')
const transcriptsDir = join(cliDir, directory)
await mkdir(transcriptsDir, { recursive: true })
await writeFile(
join(transcriptsDir, 'devin-shown.json'),
JSON.stringify({ session_id: 'shown', steps: [] })
)
await writeFile(
join(transcriptsDir, 'hidden.json'),
JSON.stringify({ session_id: 'hidden', steps: [] })
)
const dbPath = join(cliDir, 'sessions.db')
writeDevinSessionsDb(dbPath, [
{
id: 'shown',
working_directory: '/srv/shown',
title: 'Shown session',
model: 'swe-1-6-fast',
created_at: DEVIN_CREATED_S,
last_activity_at: DEVIN_ACTIVITY_S
},
{ id: 'hidden', hidden: 1 }
])
return { transcriptsDir, dbPath }
}
it.each(['transcripts', 'agent_logs'])(
'enriches %s sessions by session_id and excludes hidden ones',
async (directory) => {
const root = await tempDir('orca-devin-scan-')
const { transcriptsDir } = await writeDevinVault(root, directory)
const result = await scanAiVaultSessions({
...isolatedScanRoots(root),
devinTranscriptsDir: transcriptsDir
})
const devin = result.sessions.filter((session) => session.agent === 'devin')
expect(devin.map((session) => session.sessionId)).toEqual(['shown'])
expect(devin[0]?.cwd).toBe('/srv/shown')
expect(devin[0]?.title).toBe('Shown session')
expect(devin[0]?.model).toBe('swe-1-6-fast')
expect(devin[0]?.createdAt).toBe(new Date(DEVIN_CREATED_S * 1000).toISOString())
}
)
it('still lists sessions when sessions.db is absent', async () => {
const root = await tempDir('orca-devin-scan-')
const transcriptsDir = join(root, 'devin-cli', 'transcripts')
await mkdir(transcriptsDir, { recursive: true })
await writeFile(
join(transcriptsDir, 'bare.json'),
JSON.stringify({
session_id: 'bare',
working_directory: '/srv/bare',
steps: []
})
)
const result = await scanAiVaultSessions({
...isolatedScanRoots(root),
devinTranscriptsDir: transcriptsDir
})
const devin = result.sessions.filter((session) => session.agent === 'devin')
expect(devin.map((session) => session.sessionId)).toEqual(['bare'])
expect(devin[0]?.cwd).toBe('/srv/bare')
})
it('picks up db-only changes on a rescan without the transcript moving', async () => {
const root = await tempDir('orca-devin-scan-')
const { transcriptsDir, dbPath } = await writeDevinVault(root)
const options = {
...isolatedScanRoots(root),
devinTranscriptsDir: transcriptsDir
}
await scanAiVaultSessions(options)
// Unhide and retitle; bump mtime so the sidecar stat reads as changed even
// on a coarse-granularity filesystem.
writeDevinSessionsDb(dbPath, [
{ id: 'shown', working_directory: '/srv/moved', title: 'Retitled' },
{
id: 'hidden',
hidden: 0,
working_directory: '/srv/unhidden',
title: 'Back again'
}
])
const bumped = Date.now() + 10_000
await utimes(dbPath, new Date(bumped), new Date(bumped))
const result = await scanAiVaultSessions(options)
const devin = result.sessions.filter((session) => session.agent === 'devin')
const byId = new Map(devin.map((session) => [session.sessionId, session]))
expect(byId.get('shown')?.cwd).toBe('/srv/moved')
expect(byId.get('shown')?.title).toBe('Retitled')
expect(byId.get('hidden')?.cwd).toBe('/srv/unhidden')
expect(byId.get('hidden')?.title).toBe('Back again')
})
})
@@ -0,0 +1,205 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { dirname, join } from 'node:path'
import { wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
import { readOpenCodeDatabase } from './session-scanner-opencode-sqlite-open'
import type { SessionSidecarObservation } from './session-sidecar-stat'
import { asRecord } from './session-scanner-record-value'
import { numberValue } from './session-scanner-token-values'
import { extractString } from './session-scanner-values'
// Why: Devin CLI keeps a tiny `sessions` table in sessions.db beside the
// transcripts dir — the transcript holds no cwd on Windows installs, so the db
// is what lets a Devin session group under a workspace. One row per session_id
// (the transcript filename), timestamps in unix SECONDS.
export type DevinSessionIndexRow = {
workingDirectory: string | null
title: string | null
model: string | null
createdAt: string | null
lastActivityAt: string | null
// The user hid the session in Devin's own UI; the listing honors that.
hidden: boolean
}
export type DevinSessionsIndex = Map<string, DevinSessionIndexRow>
// Optional in older schemas; `id` is the only required column.
const DEVIN_SESSION_TABLE = 'sessions'
const DEVIN_SESSION_OPTIONAL_COLUMNS = [
'working_directory',
'title',
'model',
'created_at',
'last_activity_at',
'hidden'
] as const
// One index per observed db stat, so all transcripts under a root share a
// single open per scan and a db the transcript mtimes cannot see still
// re-merges when its own stat moves.
const INDEX_CACHE_LIMIT = 8
const indexCache = new Map<
string,
{ sidecarPath: string; mtimeMs: number; sizeBytes: number; index: DevinSessionsIndex }
>()
const scanDbFailures = new AsyncLocalStorage<Set<string>>()
/** A contended database must cost one timeout per scan, not one per transcript. */
export function withDevinSessionsDbScan<T>(fn: () => Promise<T>): Promise<T> {
return scanDbFailures.run(new Set(), fn)
}
/**
* The sessions.db a transcript's index lives in: it sits beside the
* transcripts dir, never inside it. Transcripts are flat files directly under
* the root, so the db is always dirname(dirname(file)).
* @param transcriptFilePath - A discovered Devin transcript path.
* @returns Absolute path to the sibling sessions.db.
*/
export function devinSessionsDbPath(transcriptFilePath: string): string {
return join(dirname(dirname(transcriptFilePath)), 'sessions.db')
}
/**
* The file whose stat should drive re-enrichment. In WAL mode, committed rows
* sit in sessions.db-wal while sessions.db keeps its stat until checkpoint, so
* a present wal is the fresher signal; a clean-close db has no wal and its own
* stat carries the change.
* @param transcriptFilePath - A discovered Devin transcript path.
* @returns Absolute path to sessions.db-wal when it exists, else sessions.db.
*/
export async function devinSessionsDbDependencyPath(transcriptFilePath: string): Promise<string> {
const dbPath = devinSessionsDbPath(transcriptFilePath)
const walPath = `${dbPath}-wal`
try {
await wslGatedStat(walPath, 'scan')
return walPath
} catch {
// Missing wal is the common case; a refused probe degrades to watching
// the db itself rather than taking the transcript down with it.
return dbPath
}
}
/**
* The db a dependency observation actually belongs to: the sidecar may point
* at sessions.db-wal, but SQLite is always opened on sessions.db itself.
*/
function devinSessionsDbPathForSidecarPath(sidecarPath: string): string {
return sidecarPath.endsWith('-wal') ? sidecarPath.slice(0, -'-wal'.length) : sidecarPath
}
/**
* The sessions.db index for a discovery-observed sidecar, or why there is
* none. Never throws: a missing/db-less root is `index: null`, an observed db
* that could not be read is `unreadable` (so the caller records the sidecar as
* unknown and retries next scan rather than caching un-enriched results).
* @param sidecar - The file's sidecar observation from discovery.
*/
export function devinSessionsIndexForSidecar(sidecar: SessionSidecarObservation | undefined): {
index: DevinSessionsIndex | null
unreadable: boolean
} {
if (sidecar === undefined || sidecar === 'none') {
return { index: null, unreadable: false }
}
if (sidecar === 'unknown') {
// The stat already failed this scan; an open would ride the same stalled
// share. Retry next scan instead of paying it per transcript.
return { index: null, unreadable: true }
}
const dbPath = devinSessionsDbPathForSidecarPath(sidecar.path)
const failures = scanDbFailures.getStore()
if (failures?.has(dbPath)) {
return { index: null, unreadable: true }
}
const cached = indexCache.get(dbPath)
if (
cached &&
cached.sidecarPath === sidecar.path &&
cached.mtimeMs === sidecar.mtimeMs &&
cached.sizeBytes === sidecar.sizeBytes
) {
indexCache.delete(dbPath)
indexCache.set(dbPath, cached)
return { index: cached.index, unreadable: false }
}
try {
const index = readDevinSessionsIndex(dbPath)
if (indexCache.size >= INDEX_CACHE_LIMIT) {
const oldest = indexCache.keys().next().value
if (oldest !== undefined) {
indexCache.delete(oldest)
}
}
indexCache.set(dbPath, {
sidecarPath: sidecar.path,
mtimeMs: sidecar.mtimeMs,
sizeBytes: sidecar.sizeBytes,
index
})
return { index, unreadable: false }
} catch {
// Retry next scan even if the database stat has not changed.
failures?.add(dbPath)
return { index: null, unreadable: true }
}
}
export function resetDevinSessionsIndexCacheForTests(): void {
indexCache.clear()
}
/**
* Read every session row from a Devin sessions.db. Read-only plus query_only,
* same open policy as the OpenCode db (which also picks the busy timeout: 0
* over a WSL share, where SQLite locks can never be taken). Older schemas
* missing optional columns still yield rows; a db without the sessions table
* or its id column yields an empty index.
* @param dbPath - Absolute path to a sessions.db file.
* @returns Rows keyed by session id; rethrows whatever SQLite raised.
*/
function readDevinSessionsIndex(dbPath: string): DevinSessionsIndex {
return readOpenCodeDatabase({
dbPath,
read: (db) => {
const index: DevinSessionsIndex = new Map()
if (!tableExists(db, DEVIN_SESSION_TABLE) || !columnExists(db, DEVIN_SESSION_TABLE, 'id')) {
return index
}
const columns = DEVIN_SESSION_OPTIONAL_COLUMNS.filter((column) =>
columnExists(db, DEVIN_SESSION_TABLE, column)
)
const statement = db.prepare(
`SELECT id${columns.map((column) => `, ${column}`).join('')} FROM ${DEVIN_SESSION_TABLE}`
)
for (const row of statement.all()) {
const record = asRecord(row)
const id = record ? extractString(record.id) : null
if (!record || !id) {
continue
}
index.set(id, {
workingDirectory: extractString(record.working_directory),
title: extractString(record.title),
model: extractString(record.model),
createdAt: unixSecondsToIso(record.created_at),
lastActivityAt: unixSecondsToIso(record.last_activity_at),
hidden: numberValue(record.hidden) !== 0
})
}
return index
}
})
}
function unixSecondsToIso(value: unknown): string | null {
const seconds = numberValue(value)
if (seconds <= 0) {
return null
}
const date = new Date(seconds * 1000)
return Number.isFinite(date.getTime()) ? date.toISOString() : null
}
@@ -0,0 +1,108 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { isolatedScanRoots } from './session-scanner-test-fixtures'
import { parseDevinSessionContent } from './session-scanner-devin-parser'
import { dedupeScannedSessions, ScannedSessionCollection } from './session-root-dedup'
const roots: string[] = []
afterEach(async () => {
vi.unstubAllEnvs()
vi.resetModules()
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
function transcript(sessionId: string, timestamp = '2026-09-19T00:00:00Z'): string {
return JSON.stringify({
schema_version: 'ATIF-v1.7',
session_id: sessionId,
steps: [{ source: 'user', message: sessionId, timestamp }]
})
}
it('lists a session in both default directories once and still fills the scan limit', async () => {
const root = await mkdtemp(join(tmpdir(), 'devin-dedup-'))
roots.push(root)
vi.stubEnv('DEVIN_HOME', root)
vi.resetModules()
for (const dir of ['transcripts', 'agent_logs']) {
await mkdir(join(root, dir))
await writeFile(join(root, dir, `${dir}-same.json`), transcript('same'))
}
await writeFile(
join(root, 'transcripts', 'other.json'),
transcript('other', '2026-09-18T00:00:00Z')
)
const { scanAiVaultSessions } = await import('./session-scanner')
const { devinTranscriptsDir: _unused, ...options } = isolatedScanRoots(root)
const result = await scanAiVaultSessions({ ...options, limit: 2 })
expect(result.sessions.map((session) => session.sessionId)).toEqual(['same', 'other'])
const unlimited = await scanAiVaultSessions({ ...options, unlimited: true })
expect(unlimited.sessions.map((session) => session.sessionId)).toEqual(['same', 'other'])
})
it.each(['win32-x64', 'linux-x64'] as const)(
'deduplicates both remote directories on %s',
async (platform) => {
const provider = new MemoryRemoteProvider()
const windows = platform === 'win32-x64'
const home = windows ? 'C:/Users/ada' : '/home/ada'
const cliDir = `${home}/${windows ? 'AppData/Roaming' : '.local/share'}/devin/cli`
provider.addFile(`${cliDir}/transcripts/same.json`, transcript('same'), 10)
provider.addFile(`${cliDir}/agent_logs/devin-same.json`, transcript('same'), 20)
provider.addFile(
`${cliDir}/transcripts/other.json`,
transcript('other', '2026-09-18T00:00:00Z'),
5
)
const result = await scanRemoteAiVaultSessions({
provider,
remoteHome: home,
executionHostId: 'ssh:devin',
hostPlatform: getRemoteHostPlatform(platform),
limit: 2
})
expect(result.sessions.map((session) => session.sessionId)).toEqual(['same', 'other'])
expect(result.sessions[0].filePath).toBe(`${cliDir}/agent_logs/devin-same.json`)
}
)
function session(path: string) {
const parsed = parseDevinSessionContent(
{ path, mtimeMs: 100, sizeBytes: 1, modifiedAt: new Date(100).toISOString() },
transcript('same'),
'win32'
)
if (!parsed) {
throw new Error('Devin fixture did not parse')
}
return parsed
}
it('keeps the newest export, prefers agent_logs on ties, and isolates execution hosts and installs', () => {
const older = session('C:/Users/ada/devin/transcripts/same.json')
const current = session('C:/Users/ada/devin/agent_logs/devin-same.json')
const newer = { ...older, modifiedAt: '2026-09-20T00:00:00Z' }
const remote = { ...current, executionHostId: 'ssh:other' as const }
const wsl = session('\\\\wsl$\\Ubuntu\\home\\ada\\devin\\agent_logs\\devin-same.json')
const otherInstall = session('C:/Users/other/devin/agent_logs/devin-same.json')
for (const rows of [
[older, current],
[current, older]
]) {
expect(dedupeScannedSessions(rows)).toEqual([current])
}
const rows = [older, current, remote, wsl, otherInstall, newer]
const expected = [remote, wsl, otherInstall, newer]
expect(dedupeScannedSessions(rows)).toEqual(expected)
const collection = new ScannedSessionCollection()
for (const row of rows) {
collection.add(row)
}
expect([...collection.values()]).toEqual(expected)
expect(collection.size).toBe(4)
})
@@ -92,4 +92,122 @@ describe('parseDevinSessionFile', () => {
text: 'Done'
})
})
it('extracts text from an array-valued ATIF message', async () => {
const dir = await mkdtemp(join(tmpdir(), 'orca-devin-parser-'))
tempDirs.push(dir)
const path = join(dir, 'array-message.json')
const mtimeMs = Date.now()
await writeFile(
path,
JSON.stringify({
session_id: 'array-message',
agent: {},
steps: [
{
timestamp: '2026-05-26T00:00:00Z',
source: 'user',
message: [{ text: 'First part' }, { text: 'second part' }]
}
]
})
)
const session = await parseDevinSessionFile({
path,
mtimeMs,
modifiedAt: new Date(mtimeMs).toISOString()
})
expect(session?.messageCount).toBe(1)
expect(session?.title).toBe('First part second part')
expect(session?.previewMessages[0]).toMatchObject({
role: 'user',
text: 'First part second part'
})
})
it('parses a real ATIF-v1.7 transcript', async () => {
const dir = await mkdtemp(join(tmpdir(), 'orca-devin-parser-'))
tempDirs.push(dir)
const path = join(dir, 'apricot-houseboat.json')
const mtimeMs = Date.now()
await writeFile(
path,
JSON.stringify({
schema_version: 'ATIF-v1.7',
session_id: 'apricot-houseboat',
agent: {
name: 'devin',
version: '3000.10.27',
model_name: 'SWE-2 High',
tool_definitions: []
},
steps: [
{
step_id: 8,
timestamp: '2026-09-16T09:50:40.000000000+00:00',
source: 'system',
message: 'You are Devin, an AI software engineer.'
},
{
step_id: 9,
timestamp: '2026-09-16T09:50:55.745112900+00:00',
source: 'user',
message: 'fix toàn bộ các lỗi này đi',
extra: { telemetry: { source: 'user', operation: 'unknown' } }
},
{
step_id: 10,
timestamp: '2026-09-16T09:51:02.1+00:00',
source: 'agent',
message: '# Báo cáo\n\nĐã sửa xong.',
tool_calls: [],
model_name: 'swe-2-high',
metrics: { prompt_tokens: 174988, completion_tokens: 2061, cached_tokens: 170575 },
extra: {
generation_model: 'swe-2-high',
telemetry: { source: 'assistant', operation: 'inference' }
}
},
{
step_id: 11,
timestamp: '2026-09-16T09:51:30.0+00:00',
source: 'agent',
message: 'All checks pass.',
metrics: { prompt_tokens: 100, completion_tokens: 10, cached_tokens: 50 },
extra: { telemetry: { source: 'assistant', operation: 'inference' } }
}
],
final_metrics: {
total_prompt_tokens: 175088,
total_completion_tokens: 2071,
total_cached_tokens: 170625,
total_steps: 11
}
})
)
const session = await parseDevinSessionFile({
path,
mtimeMs,
modifiedAt: new Date(mtimeMs).toISOString()
})
expect(session).not.toBeNull()
expect(session?.sessionId).toBe('apricot-houseboat')
expect(session?.model).toBe('SWE-2 High')
expect(session?.title).toBe('fix toàn bộ các lỗi này đi')
expect(session?.messageCount).toBe(3)
// prompt + completion per step; cached_tokens is already inside prompt_tokens.
expect(session?.totalTokens).toBe(177159)
expect(session?.updatedAt).toBe('2026-09-16T09:51:30.000Z')
expect(session?.previewMessages.length).toBeGreaterThan(0)
for (const preview of session?.previewMessages ?? []) {
expect(['user', 'assistant']).toContain(preview.role)
expect(preview.text).toBeTruthy()
}
expect(session?.previewMessages.some((preview) => preview.role === 'user')).toBe(true)
expect(session?.previewMessages.some((preview) => preview.role === 'assistant')).toBe(true)
})
})
@@ -78,40 +78,48 @@ function parseDevinSessionRecord(
}
function extractDevinStepText(step: Record<string, unknown>): string | null {
// ATIF-v1.7 carries `message` as a plain string; older shapes wrap it as {content}.
const messageText = extractString(step.message)
if (messageText) {
return messageText
}
const message = asRecord(step.message)
if (message) {
return extractContentText(message.content) ?? extractString(message.content)
}
return extractString(step.text)
// ATIF also allows `message` as an array of content parts.
return extractContentText(step.message) ?? extractString(step.text)
}
// Each bucket resolves from the first source that reports it. ATIF
// `prompt_tokens` already includes `cached_tokens`, so only the Claude-style
// cache keys (which sit outside input_tokens) are summed.
function devinStepTokenTotal(
metadata: Record<string, unknown> | null,
metrics: Record<string, unknown> | null
metrics: Record<string, unknown> | null,
stepMetrics: Record<string, unknown> | null
): number {
const sources = [metadata, metrics, stepMetrics]
return (
numberFromDevinMetadata(metadata, metrics, ['total_input_tokens', 'input_tokens']) +
numberFromDevinMetadata(metadata, metrics, ['output_tokens']) +
numberFromDevinMetadata(metadata, metrics, ['cache_read_tokens', 'cache_read_input_tokens']) +
numberFromDevinMetadata(metadata, metrics, [
'cache_creation_tokens',
'cache_creation_input_tokens'
])
firstDevinMetricValue(sources, ['total_input_tokens', 'input_tokens', 'prompt_tokens']) +
firstDevinMetricValue(sources, ['output_tokens', 'completion_tokens']) +
firstDevinMetricValue(sources, ['cache_read_tokens', 'cache_read_input_tokens']) +
firstDevinMetricValue(sources, ['cache_creation_tokens', 'cache_creation_input_tokens'])
)
}
function numberFromDevinMetadata(
metadata: Record<string, unknown> | null,
metrics: Record<string, unknown> | null,
function firstDevinMetricValue(
sources: readonly (Record<string, unknown> | null)[],
keys: readonly string[]
): number {
for (const source of [metadata, metrics]) {
for (const source of sources) {
if (!source) {
continue
}
for (const key of keys) {
const value = numberValue(source[key])
if (value > 0) {
const rawValue = source[key]
const value = numberValue(rawValue)
if (value > 0 || (value === 0 && typeof rawValue === 'number' && Number.isFinite(rawValue))) {
return value
}
}
@@ -125,12 +133,23 @@ export function consumeDevinSessionStep(accumulator: SessionAccumulator, step: u
return
}
const metadata = asRecord(stepRecord.metadata)
updateTimeline(accumulator, extractString(metadata?.created_at))
updateTimeline(
accumulator,
extractString(stepRecord.timestamp) ?? extractString(metadata?.created_at)
)
const metrics = asRecord(metadata?.metrics)
const extra = asRecord(stepRecord.extra)
accumulator.model ??=
extractString(metadata?.generation_model) ?? extractString(metrics?.generation_model)
accumulator.totalTokens += devinStepTokenTotal(metadata, metrics)
const isUser = metadata?.is_user_input === true
extractString(stepRecord.model_name) ??
extractString(extra?.generation_model) ??
extractString(metadata?.generation_model) ??
extractString(metrics?.generation_model)
accumulator.totalTokens += devinStepTokenTotal(metadata, metrics, asRecord(stepRecord.metrics))
// ATIF `source` is 'user' | 'agent' | 'system'; system steps are setup noise
// that must not count as messages or feed title/preview.
const source = extractString(stepRecord.source)
const isSystem = source === 'system'
const isUser = !isSystem && (source === 'user' || metadata?.is_user_input === true)
if (isUser) {
accumulator.messageCount++
const text =
@@ -142,7 +161,10 @@ export function consumeDevinSessionStep(accumulator: SessionAccumulator, step: u
accumulator.title ??= titleCandidate
}
addPreviewContent(accumulator, 'user', text ?? stepRecord.content)
} else if (extractString(stepRecord.role) === 'assistant' || stepRecord.tool_calls) {
} else if (
!isSystem &&
(source === 'agent' || extractString(stepRecord.role) === 'assistant' || stepRecord.tool_calls)
) {
accumulator.messageCount++
addPreviewContent(
accumulator,
@@ -89,7 +89,13 @@ export type SessionParseStats = TranscriptReadStats & {
}
export function createSessionParseStats(): SessionParseStats {
return { reused: 0, incremental: 0, fullParses: 0, earlyStopped: 0, bytesRead: 0 }
return {
reused: 0,
incremental: 0,
fullParses: 0,
earlyStopped: 0,
bytesRead: 0
}
}
/**
@@ -209,17 +215,22 @@ async function parseCachedInLane(
}
const session = await readWholeTranscript({ candidate, platform, stats })
// Whole-file agents merge the sibling here just like the resumable branch
// does post-read; the raw fold stays in foldSession so a sibling-only change
// re-merges without re-reading the transcript.
const enriched = await enrichSessionFromSidecar(candidate, session, platform)
storeSessionParseCacheEntry(file.path, {
mtimeMs: file.mtimeMs,
sizeBytes: file.sizeBytes ?? null,
platform,
session,
// A whole-file parse reads the sibling itself, so a change to it re-parses.
sidecar: file.sidecar,
session: enriched.session,
// A refused sibling keeps the transcript's own result cached; only the
// sibling is recorded as unknown, so the next scan re-merges.
sidecar: enriched.refused ? 'unknown' : file.sidecar,
foldSession: session,
resume: null
})
return session
return enriched.session
}
async function reuseCachedSession(
@@ -92,6 +92,12 @@ describe('buildAiVaultServiceEnv', () => {
expect(env.PATH).toBe('C:\\bin')
})
it('passes a relocated AppData through so Devin resolves its Windows data root', () => {
const env = buildAiVaultServiceEnv({ AppData: 'D:\\Roaming' }, 'win32')
expect(env.APPDATA).toBe('D:\\Roaming')
})
it('spells SystemRoot the way Windows Node expects', () => {
expect(buildAiVaultServiceEnv({ SystemRoot: 'C:\\Windows' }, 'win32').SystemRoot).toBe(
'C:\\Windows'
@@ -33,6 +33,9 @@ export const RUNTIME_ENV_ALLOWLIST = [
// Why: the desktop child resolves agent roots from its own environment, so
// dropping one hides every session of a user who relocated that agent's home.
const AGENT_ROOT_ENV_ALLOWLIST = [
// Why: Devin's Windows data root resolves under %APPDATA%, which managed
// machines relocate away from the profile default.
'APPDATA',
'CODEX_HOME',
'CLINE_SESSION_DATA_DIR',
'COPILOT_HOME',
@@ -2,6 +2,8 @@ import type { AiVaultSession } from '../../shared/ai-vault-types'
import { buildAiVaultResumeCommand } from '../../shared/ai-vault-resume-command'
import { generatedSessionTitle } from './session-scanner-accumulator'
import { readCursorChatMeta, wasCursorChatMetaRefused } from './session-scanner-cursor-chat-meta'
import { devinSessionsIndexForSidecar } from './session-scanner-devin-db'
import type { SessionSidecarObservation } from './session-sidecar-stat'
import type { SessionFileCandidate } from './session-scanner-types'
/**
@@ -23,7 +25,7 @@ export type SidecarEnrichment = {
/** True when the sibling only adds metadata, so a change to it needs no re-parse. */
export function sidecarEnrichesWithoutReparse(candidate: SessionFileCandidate): boolean {
return candidate.agent === 'cursor'
return candidate.agent === 'cursor' || candidate.agent === 'devin'
}
export async function enrichSessionFromSidecar(
@@ -31,14 +33,23 @@ export async function enrichSessionFromSidecar(
foldSession: AiVaultSession | null,
platform: NodeJS.Platform
): Promise<SidecarEnrichment> {
if (candidate.agent === 'devin') {
return enrichDevinSessionFromDb(candidate.file.sidecar, foldSession, platform)
}
if (candidate.agent !== 'cursor' || !foldSession) {
return { session: foldSession, refused: false }
}
const meta = await readCursorChatMeta(candidate.file.path)
if (!meta) {
return { session: foldSession, refused: wasCursorChatMetaRefused(candidate.file.path) }
return {
session: foldSession,
refused: wasCursorChatMetaRefused(candidate.file.path)
}
}
return {
session: mergeCursorChatMeta(foldSession, meta, platform),
refused: false
}
return { session: mergeCursorChatMeta(foldSession, meta, platform), refused: false }
}
/** Fills only what the transcript never recorded; its own records always win. */
@@ -77,3 +88,47 @@ export function mergeCursorChatMeta(
})
}
}
/**
* Merge the Devin sessions.db row onto the transcript's session. Devin's
* sibling is one shared index for the whole transcripts dir rather than a
* per-session file, so this also decides membership: a row the user hid in
* Devin's UI drops the session from the listing entirely.
*/
function enrichDevinSessionFromDb(
sidecar: SessionSidecarObservation | undefined,
foldSession: AiVaultSession | null,
platform: NodeJS.Platform
): SidecarEnrichment {
if (!foldSession) {
return { session: foldSession, refused: false }
}
const { index, unreadable } = devinSessionsIndexForSidecar(sidecar)
if (!index) {
// An observed-but-unreadable db must not settle as "no enrichment": the
// refusal keeps the sidecar unknown so the next scan retries the merge.
return { session: foldSession, refused: unreadable }
}
const row = index.get(foldSession.sessionId)
if (!row) {
return { session: foldSession, refused: false }
}
if (row.hidden) {
return { session: null, refused: false }
}
const merged = mergeCursorChatMeta(
foldSession,
{
title: row.title,
cwd: row.workingDirectory,
createdAt: row.createdAt,
updatedAt: row.lastActivityAt
},
platform
)
return {
session:
foldSession.model === null && row.model !== null ? { ...merged, model: row.model } : merged,
refused: false
}
}
@@ -1,5 +1,5 @@
import { beforeEach, expect, it, vi } from 'vitest'
import type * as CodexDedup from './codex-session-root-dedup'
import type * as SessionDedup from './session-root-dedup'
import type { AiVaultSession } from '../../shared/ai-vault-types'
const fixture = vi.hoisted((): { sessions: AiVaultSession[]; visits: number } => ({
@@ -36,20 +36,20 @@ vi.mock('./remote-session-parse-cache', () => ({
parseRemoteSessionFileCached: async ({ candidate }: { candidate: { session: AiVaultSession } }) =>
candidate.session
}))
vi.mock('./codex-session-root-dedup', async (original) => {
const actual = await original<typeof CodexDedup>()
vi.mock('./session-root-dedup', async (original) => {
const actual = await original<typeof SessionDedup>()
return {
...actual,
dedupeCodexSessionsBySessionId: (sessions: AiVaultSession[]) => {
dedupeScannedSessions: (sessions: AiVaultSession[]) => {
fixture.visits += sessions.length
return actual.dedupeCodexSessionsBySessionId(sessions)
return actual.dedupeScannedSessions(sessions)
}
}
})
import { scanAiVaultSessions } from './session-scanner'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { CodexSessionCollection, dedupeCodexSessionsBySessionId } from './codex-session-root-dedup'
import { ScannedSessionCollection, dedupeScannedSessions } from './session-root-dedup'
function candidates() {
return fixture.sessions.map((session) => ({
@@ -120,7 +120,7 @@ for (const host of ['local', 'remote'] as const) {
filePath: '/custom/rollout-0.jsonl'
}
fixture.sessions.push(session(0))
const expected = dedupeCodexSessionsBySessionId(fixture.sessions)
const expected = dedupeScannedSessions(fixture.sessions)
fixture.visits = 0
const started = performance.now()
const result = await scan(true)
@@ -149,7 +149,7 @@ for (const host of ['local', 'remote'] as const) {
}
it('incremental canonical selection preserves winner occurrence order, ties and repeated references', () => {
const collection = new CodexSessionCollection()
const collection = new ScannedSessionCollection()
const same = session(0)
const rows: AiVaultSession[] = []
const variants: AiVaultSession[] = [
@@ -169,12 +169,12 @@ it('incremental canonical selection preserves winner occurrence order, ties and
const row = variants[seed % variants.length]!
rows.push(row)
collection.add(row)
expect([...collection.values()]).toEqual(dedupeCodexSessionsBySessionId(rows))
expect([...collection.values()]).toEqual(dedupeScannedSessions(rows))
}
})
it('retains only canonical rows during duplicate-heavy load-all scans', () => {
const collection = new CodexSessionCollection()
const collection = new ScannedSessionCollection()
for (let index = 0; index < 10000; index++) {
const row = session(index % 100)
collection.add({
@@ -194,7 +194,7 @@ it('retains only canonical rows during duplicate-heavy load-all scans', () => {
it('admits rows sharing one session id across rollout names without rescanning', () => {
const count = 4000
let pathReads = 0
const collection = new CodexSessionCollection()
const collection = new ScannedSessionCollection()
for (let index = 0; index < count; index++) {
const row = { ...session(index), sessionId: 'shared' }
collection.add({
@@ -230,9 +230,9 @@ it('bounds per-session bookkeeping for a large mostly-unique load-all corpus', (
}
: session(index)
)
const expected = dedupeCodexSessionsBySessionId(corpus)
const expected = dedupeScannedSessions(corpus)
const before = heapUsed()
const collection = new CodexSessionCollection()
const collection = new ScannedSessionCollection()
for (const row of corpus) {
collection.add(row)
}
+76 -73
View File
@@ -6,7 +6,7 @@ import type {
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
import { withSpan } from '../observability/tracer'
import { sessionSortTime } from './session-scanner-accumulator'
import { CodexSessionCollection, dedupeCodexSessionsBySessionId } from './codex-session-root-dedup'
import { ScannedSessionCollection, dedupeScannedSessions } from './session-root-dedup'
import {
createAntigravityWorkspaceResolver,
readLocalAntigravityHistory,
@@ -36,6 +36,7 @@ import type {
import { clampPositiveInteger, errorMessage } from './session-scanner-values'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { DEFAULT_AI_VAULT_SCAN_LIMIT } from '../../shared/ai-vault-session-depth'
import { withDevinSessionsDbScan } from './session-scanner-devin-db'
const SESSION_PARSE_CONCURRENCY = 8
const SESSION_PARSE_CANDIDATE_MULTIPLIER = 2
@@ -58,83 +59,85 @@ export async function scanAiVaultSessions(
// The Cursor chat-meta scope spans discovery AND parse: its sibling meta.json
// is looked up in both phases, and one scan must read the chats tree once.
return withSpan('aiVault.scan', (span) =>
withCursorChatMetaScan(async () => {
const limit = options.unlimited
? Number.POSITIVE_INFINITY
: clampPositiveInteger(options.limit, DEFAULT_AI_VAULT_SCAN_LIMIT)
const limitPerAgent = options.unlimited
? Number.POSITIVE_INFINITY
: clampPositiveInteger(options.limitPerAgent, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER)
const platform = options.platform ?? process.platform
const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
const issues: AiVaultScanIssue[] = []
const parseStats = createSessionParseStats()
const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(
readLocalAntigravityHistory
)
// Why: persisted entries must be seeded before any candidate is parsed, or
// the cold scan gains nothing from the cache file (#9210).
throwIfAiVaultScanCancelled(options.signal)
await ensureSessionParseCacheLoaded()
const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues })
throwIfAiVaultScanCancelled(options.signal)
withDevinSessionsDbScan(() =>
withCursorChatMetaScan(async () => {
const limit = options.unlimited
? Number.POSITIVE_INFINITY
: clampPositiveInteger(options.limit, DEFAULT_AI_VAULT_SCAN_LIMIT)
const limitPerAgent = options.unlimited
? Number.POSITIVE_INFINITY
: clampPositiveInteger(options.limitPerAgent, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER)
const platform = options.platform ?? process.platform
const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
const issues: AiVaultScanIssue[] = []
const parseStats = createSessionParseStats()
const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(
readLocalAntigravityHistory
)
// Why: persisted entries must be seeded before any candidate is parsed, or
// the cold scan gains nothing from the cache file (#9210).
throwIfAiVaultScanCancelled(options.signal)
await ensureSessionParseCacheLoaded()
const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues })
throwIfAiVaultScanCancelled(options.signal)
const candidates = await sessionCandidatesFromDiscoveries(discoveries, options)
const candidates = await sessionCandidatesFromDiscoveries(discoveries, options)
const parsedSessions = await parseSessionCandidates({
candidates: candidates.slice(0, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER),
limit,
platform,
executionHostId,
issues,
parseStats,
signal: options.signal,
antigravityWorkspaceResolver
})
const cappedSessions = dedupeCodexSessionsBySessionId(parsedSessions)
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit)
const scopeSessions = await scanInScopeSessions({
discoveries,
scopePaths: options.scopePaths ?? [],
limit,
alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)),
platform,
executionHostId,
issues,
parseStats,
signal: options.signal
})
// Scope discovery can return without parsing anything, so an abort landing
// here would otherwise persist and return a cancelled scan as complete.
throwIfAiVaultScanCancelled(options.signal)
for (const refusal of cursorChatMetaRefusals()) {
// One issue per refused chats root, not one per Cursor transcript.
recordSessionScanIssue(issues, {
agent: 'cursor',
path: refusal.chatsRoot,
message: refusal.message
const parsedSessions = await parseSessionCandidates({
candidates: candidates.slice(0, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER),
limit,
platform,
executionHostId,
issues,
parseStats,
signal: options.signal,
antigravityWorkspaceResolver
})
}
span.setAttribute('candidates', candidates.length)
span.setAttribute('reused', parseStats.reused)
span.setAttribute('incremental', parseStats.incremental)
span.setAttribute('fullParses', parseStats.fullParses)
span.setAttribute('earlyStopped', parseStats.earlyStopped)
span.setAttribute('bytesRead', parseStats.bytesRead)
span.setAttribute('issues', issues.length)
const cappedSessions = dedupeScannedSessions(parsedSessions)
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit)
scheduleSessionParseCachePersist(parseStats)
const scopeSessions = await scanInScopeSessions({
discoveries,
scopePaths: options.scopePaths ?? [],
limit,
alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)),
platform,
executionHostId,
issues,
parseStats,
signal: options.signal
})
// Scope discovery can return without parsing anything, so an abort landing
// here would otherwise persist and return a cancelled scan as complete.
throwIfAiVaultScanCancelled(options.signal)
for (const refusal of cursorChatMetaRefusals()) {
// One issue per refused chats root, not one per Cursor transcript.
recordSessionScanIssue(issues, {
agent: 'cursor',
path: refusal.chatsRoot,
message: refusal.message
})
}
return {
sessions: mergeSessions(cappedSessions, scopeSessions),
issues: issues.map((issue) => ({ executionHostId, ...issue })),
scannedAt: new Date().toISOString()
}
})
span.setAttribute('candidates', candidates.length)
span.setAttribute('reused', parseStats.reused)
span.setAttribute('incremental', parseStats.incremental)
span.setAttribute('fullParses', parseStats.fullParses)
span.setAttribute('earlyStopped', parseStats.earlyStopped)
span.setAttribute('bytesRead', parseStats.bytesRead)
span.setAttribute('issues', issues.length)
scheduleSessionParseCachePersist(parseStats)
return {
sessions: mergeSessions(cappedSessions, scopeSessions),
issues: issues.map((issue) => ({ executionHostId, ...issue })),
scannedAt: new Date().toISOString()
}
})
)
)
}
@@ -212,7 +215,7 @@ async function parseSessionCandidates(args: {
signal?: AbortSignal
antigravityWorkspaceResolver?: AntigravityWorkspaceResolver
}): Promise<AiVaultSession[]> {
const sessions = new CodexSessionCollection()
const sessions = new ScannedSessionCollection()
let index = 0
while (index < args.candidates.length) {
+2 -2
View File
@@ -26,7 +26,7 @@ describe.each([
['dedicated OMP', { kind: 'omp' as const }],
['Pi-routed OMP', { kind: 'pi' as const, title: 'omp' }]
])('%s transcript metadata', (_name, args) => {
it('resolves custom files without scanning and retains id-based resume across switches', async () => {
it('resolves custom files without scanning and retains path-based resume across switches', async () => {
const harness = createAgentStatusExtensionHarness(args)
let id = ''
let file = ''
@@ -44,7 +44,7 @@ describe.each([
const payload = JSON.parse(String(harness.fetchMock.mock.lastCall?.[1]?.body)).payload
const session = extractAgentProviderSession('omp', payload)
expect(session).toEqual({ key: 'session_id', id, transcriptPath: file })
expect(getAgentResumeArgv('omp', session!)).toEqual(['omp', '--resume', id])
expect(getAgentResumeArgv('omp', session!)).toEqual(['omp', '--resume', file])
expect(getAgentResumeArgv('omp', session!, 'explicit.jsonl')).toEqual([
'omp',
'--resume',
@@ -90,7 +90,7 @@ it('prepares the execution host OMP config and status extension for a guarded la
source.mockReturnValue(false)
expect(
augment.mock.calls[1][0]({ id: 'other', shell: '/bin/bash', env: {}, command: 'codex' })
await augment.mock.calls[1][0]({ id: 'other', shell: '/bin/bash', env: {}, command: 'codex' })
).toEqual({})
} finally {
runtime.stop()
@@ -29,7 +29,9 @@
* React commits: retained panes 1 1
* sleeping-agent records read 19,711 0
* agent-status rows read 0 0
* workspace tab buckets read 1,394 1,394
* workspace tab buckets read 1,394 2,081
*
* The current count also includes the later per-workspace sleep-state reader.
*
* So notification work is O(mounted workspaces) and this fix does not change
* that — one shared store means every subscriber is visited. What changes is
@@ -392,9 +394,9 @@ describe('one pane title update: fanout at live-capture scale', () => {
// the 5,500 subscribers, not only the three instrumented modules.
expect(reads.sleepingRecords).toBe(0)
expect(reads.agentStatusRows).toBe(0)
// One bucket lookup per workspace consumer is a keyed read; a full-inventory
// walk would be that many times 870.
expect(reads.workspaceTabBuckets).toBeLessThan(WORKSPACE_COUNT * 2)
// Activity status, card inputs, and sleep state each read their own bucket;
// a global inventory scan per consumer would multiply this by 870.
expect(reads.workspaceTabBuckets).toBeLessThan(WORKSPACE_COUNT * 3)
// Only the workspace that owns the changed pane commits — the other 869
// sidebar rows hold their identities and bail out.
@@ -9,6 +9,7 @@ import { createTestStore, makeTab } from '../../src/renderer/src/store/slices/st
const PANE = 'tab-1:11111111-1111-4111-8111-111111111111'
const ROOT_SESSION = '22222222-2222-4222-8222-222222222222'
const ROOT_TRANSCRIPT_PATH = '/sessions/root.jsonl'
const CHILD_SESSION = '33333333-3333-4333-8333-333333333333'
async function rootWithActiveChild(): Promise<ReturnType<typeof createTestStore>> {
@@ -59,7 +60,7 @@ async function rootWithActiveChild(): Promise<ReturnType<typeof createTestStore>
await Promise.resolve()
}
}
const root = { getSessionId: () => ROOT_SESSION, getSessionFile: () => '/sessions/root.jsonl' }
const root = { getSessionId: () => ROOT_SESSION, getSessionFile: () => ROOT_TRANSCRIPT_PATH }
await emit('session_start', {}, root)
await emit('before_agent_start', { prompt: 'ROOT distinctive user request' }, root)
await emit(
@@ -109,7 +110,7 @@ describe('OMP child lifecycle recovery boundaries', () => {
expect(getAgentResumeArgv(record.agent, record.providerSession)).toEqual([
'omp',
'--resume',
ROOT_SESSION
ROOT_TRANSCRIPT_PATH
])
const startup = buildAgentResumeStartupPlan({
agent: record.agent,
@@ -118,7 +119,7 @@ describe('OMP child lifecycle recovery boundaries', () => {
platform: 'linux',
...record.launchConfig
})
expect(startup?.launchCommand).toBe(`omp '--resume' '${ROOT_SESSION}'`)
expect(startup?.launchCommand).toBe(`omp '--resume' '${ROOT_TRANSCRIPT_PATH}'`)
expect(serialized).not.toContain(CHILD_SESSION)
})
})