perf(team): cache transcript affinity verdicts

This commit is contained in:
777genius 2026-05-30 18:39:16 +03:00
parent 0a8fbc9801
commit 180bdb7575
6 changed files with 1332 additions and 46 deletions

View file

@ -8,13 +8,22 @@ import {
} from '@main/utils/pathDecoder';
import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { createHash } from 'crypto';
import { type Dirent } from 'fs';
import * as fs from 'fs/promises';
import * as path from 'path';
import { StringDecoder } from 'string_decoder';
import { JsonTeamTranscriptAffinityIndexStore } from './cache/JsonTeamTranscriptAffinityIndexStore';
import { TeamConfigReader } from './TeamConfigReader';
import type {
PersistedTeamTranscriptAffinityEntry,
PersistedTeamTranscriptAffinityIndex,
TeamTranscriptAffinityFileSignature,
TeamTranscriptAffinityIndexStore,
TeamTranscriptAffinityMatchSource,
} from './cache/teamTranscriptAffinityIndexTypes';
import type { TeamConfig } from '@shared/types';
const logger = createLogger('Service:TeamTranscriptProjectResolver');
@ -63,6 +72,13 @@ interface TeamTranscriptProjectContextOptions {
includeTeamSubagentSessionDiscovery?: boolean;
}
type TeamTranscriptFileStat = {
mtimeMs: number;
size: number;
ctimeMs?: number;
isFile: () => boolean;
};
type ScannedSessionProjectMatch = Omit<SessionProjectMatch, 'projectPath'> & {
projectPath?: string;
};
@ -255,7 +271,10 @@ export interface TeamTranscriptProjectLiveBaseContext {
interface TeamAffinityFileCacheEntry {
mtimeMs: number;
size: number;
ctimeMs?: number;
belongsToTeam: boolean;
inspectedLineCount: number;
headFingerprint: string;
// True when the verdict was decided after inspecting a FULL head window
// (>= TEAM_AFFINITY_SCAN_LINES non-empty lines). For append-only transcripts the
// head is immutable, so a `false` verdict from a full window stays valid while the
@ -272,13 +291,21 @@ interface TeamAffinityHeadLineMetadata {
interface TeamAffinityHeadMetadataCacheEntry {
mtimeMs: number;
size: number;
ctimeMs?: number;
inspectedLineCount: number;
headFingerprint: string;
lines: TeamAffinityHeadLineMetadata[];
}
interface TeamAffinityEvaluation {
belongsToTeam: boolean;
inspectedLineCount: number;
matchSource: TeamTranscriptAffinityMatchSource;
}
interface TeamAffinityInspectionResult extends TeamAffinityEvaluation {
headWindowFull: boolean;
indexable: boolean;
}
export class TeamTranscriptProjectResolver {
@ -294,7 +321,8 @@ export class TeamTranscriptProjectResolver {
>();
constructor(
private readonly configReader: TeamTranscriptProjectConfigReader = new TeamConfigReader()
private readonly configReader: TeamTranscriptProjectConfigReader = new TeamConfigReader(),
private readonly affinityIndexStore: TeamTranscriptAffinityIndexStore = new JsonTeamTranscriptAffinityIndexStore()
) {}
private readConfigForObservation(teamName: string): Promise<TeamConfig | null> {
@ -388,6 +416,7 @@ export class TeamTranscriptProjectResolver {
const sessionIds = await this.discoverSessionIds(
teamName,
resolution.projectDir,
resolution.projectId,
resolvedConfig,
options
);
@ -538,6 +567,7 @@ export class TeamTranscriptProjectResolver {
}
const teamRootSessionIds = await this.listTeamRootSessionIds(
dirCandidate.projectDir,
dirCandidate.projectId,
teamName
);
if (teamRootSessionIds.length > 0) {
@ -848,6 +878,7 @@ export class TeamTranscriptProjectResolver {
private async discoverSessionIds(
teamName: string,
projectDir: string,
projectId: string,
config: TeamConfig,
options?: TeamTranscriptProjectContextOptions
): Promise<string[]> {
@ -858,7 +889,7 @@ export class TeamTranscriptProjectResolver {
? null
: teamLifecycleMtimeCutoffMs(config);
const [teamRootSessionIds, teamSubagentSessionIds] = await Promise.all([
this.listTeamRootSessionIds(projectDir, teamName, rootMtimeSinceMs),
this.listTeamRootSessionIds(projectDir, projectId, teamName, rootMtimeSinceMs),
includeTeamSubagentSessionDiscovery
? this.listTeamSubagentSessionIds(projectDir, teamName)
: Promise.resolve([]),
@ -992,32 +1023,57 @@ export class TeamTranscriptProjectResolver {
private async collectRootJsonlSessionIds(
rootJsonlEntries: Dirent[],
projectDir: string,
projectId: string,
teamName: string,
mtimeSinceMs?: number | null
): Promise<string[]> {
const discovered = new Set<string>();
const rootFileNames = new Set(rootJsonlEntries.map((entry) => entry.name));
const indexEnabled = this.isPersistentAffinityIndexEnabled();
const affinityIndex = indexEnabled
? await this.loadTeamTranscriptAffinityIndex(teamName, projectId)
: null;
const shouldPruneAffinityIndex = Boolean(
affinityIndex &&
Object.keys(affinityIndex.entries).some((fileName) => !rootFileNames.has(fileName))
);
const pendingIndexEntries: PersistedTeamTranscriptAffinityEntry[] = [];
let nextIndex = 0;
const scanNextRootEntry = async (): Promise<void> => {
while (nextIndex < rootJsonlEntries.length) {
const entry = rootJsonlEntries[nextIndex++];
const filePath = path.join(projectDir, entry.name);
let precomputedStat: { mtimeMs: number; size: number; isFile: () => boolean } | undefined;
if (mtimeSinceMs != null) {
try {
const stat = await fs.stat(filePath);
if (!stat.isFile() || stat.mtimeMs < mtimeSinceMs) {
continue;
}
precomputedStat = stat;
} catch {
continue;
}
}
if (!(await this.fileBelongsToTeam(filePath, teamName, precomputedStat))) {
let fileStat: TeamTranscriptFileStat;
try {
fileStat = await fs.stat(filePath);
} catch {
continue;
}
discovered.add(entry.name.slice(0, -'.jsonl'.length));
if (!fileStat.isFile() || (mtimeSinceMs != null && fileStat.mtimeMs < mtimeSinceMs)) {
continue;
}
const indexedBelongsToTeam = indexEnabled
? this.decideTeamAffinityFromIndex(affinityIndex?.entries[entry.name], fileStat)
: null;
if (indexedBelongsToTeam !== null) {
if (indexedBelongsToTeam) {
discovered.add(entry.name.slice(0, -'.jsonl'.length));
}
continue;
}
const inspection = await this.inspectFileTeamAffinity(filePath, teamName, fileStat);
if (inspection.belongsToTeam) {
discovered.add(entry.name.slice(0, -'.jsonl'.length));
}
if (inspection.indexable) {
const indexEntry = this.buildTeamAffinityIndexEntry(entry.name, fileStat, inspection);
if (indexEntry) {
pendingIndexEntries.push(indexEntry);
}
}
}
};
@ -1027,11 +1083,26 @@ export class TeamTranscriptProjectResolver {
)
);
if (indexEnabled && (pendingIndexEntries.length > 0 || shouldPruneAffinityIndex)) {
await this.affinityIndexStore
.upsertProjectEntries({
teamName,
projectId,
projectDir,
rootFileNames,
entries: pendingIndexEntries,
})
.catch((error) => {
logger.debug(`Failed to write transcript affinity index: ${String(error)}`);
});
}
return [...discovered];
}
private async listTeamRootSessionIds(
projectDir: string,
projectId: string,
teamName: string,
mtimeSinceMs?: number | null
): Promise<string[]> {
@ -1043,49 +1114,89 @@ export class TeamTranscriptProjectResolver {
const rootJsonlEntries = dirEntries.filter(
(entry) => entry.isFile() && entry.name.endsWith('.jsonl')
);
return this.collectRootJsonlSessionIds(rootJsonlEntries, projectDir, teamName, mtimeSinceMs);
return this.collectRootJsonlSessionIds(
rootJsonlEntries,
projectDir,
projectId,
teamName,
mtimeSinceMs
);
}
private async fileBelongsToTeam(
filePath: string,
teamName: string,
precomputedStat?: { mtimeMs: number; size: number; isFile: () => boolean }
precomputedStat?: TeamTranscriptFileStat
): Promise<boolean> {
return (await this.inspectFileTeamAffinity(filePath, teamName, precomputedStat)).belongsToTeam;
}
private async inspectFileTeamAffinity(
filePath: string,
teamName: string,
precomputedStat?: TeamTranscriptFileStat
): Promise<TeamAffinityInspectionResult> {
const emptyResult: TeamAffinityInspectionResult = {
belongsToTeam: false,
inspectedLineCount: 0,
matchSource: 'none',
headWindowFull: false,
indexable: false,
};
const normalizedTeam = teamName.trim().toLowerCase();
if (!normalizedTeam) {
return false;
return emptyResult;
}
// Reuse the caller's stat when it already statted this exact file (the mtime-window
// filter in collectRootJsonlSessionIds does). On the live resolution path this drops
// a second fs.stat of the same file per entry, every poll — and using a single stat
// snapshot is also more consistent than two reads that could straddle a write.
let fileStat: { mtimeMs: number; size: number; isFile: () => boolean };
let fileStat: TeamTranscriptFileStat;
if (precomputedStat) {
fileStat = precomputedStat;
} else {
try {
fileStat = await fs.stat(filePath);
} catch {
return false;
return emptyResult;
}
}
if (!fileStat.isFile()) {
return false;
return emptyResult;
}
const cacheKey = this.buildTeamAffinityFileCacheKey(filePath, normalizedTeam);
const cached = this.teamAffinityFileCache.get(cacheKey);
if (cached) {
if (this.teamTranscriptFileSignaturesMatch(cached, fileStat)) {
return {
belongsToTeam: cached.belongsToTeam,
inspectedLineCount: 0,
matchSource: 'none',
headWindowFull: cached.headWindowFull,
indexable: false,
};
}
// A positive affinity is decided by early "head" lines that persist as an
// append-only transcript grows, so a `true` result stays valid while the file
// only grows (size >= cached). This avoids re-streaming the team's own
// continuously-growing transcripts on every bootstrap poll. A `false` result
// is still re-checked on any change, since a short file may later grow head
// lines that mention the team; a shrink (rewrite/truncate) also forces a re-scan.
if (cached.belongsToTeam && fileStat.size >= cached.size) {
return true;
if (
cached.belongsToTeam &&
fileStat.size >= cached.size &&
(await this.isCachedTeamAffinityHeadCurrent(filePath, cached))
) {
return {
belongsToTeam: true,
inspectedLineCount: 0,
matchSource: 'none',
headWindowFull: cached.headWindowFull,
indexable: false,
};
}
// A `false` decided from a FULL head window is durable while the file only
// grows: the first TEAM_AFFINITY_SCAN_LINES lines of an append-only transcript
@ -1094,27 +1205,45 @@ export class TeamTranscriptProjectResolver {
// re-scan below, identically to the positive path. This is the main launch win:
// non-matching transcripts in the project dir are no longer re-streamed +
// re-parsed on every bootstrap poll.
if (!cached.belongsToTeam && cached.headWindowFull && fileStat.size >= cached.size) {
return false;
}
if (cached.mtimeMs === fileStat.mtimeMs && cached.size === fileStat.size) {
return cached.belongsToTeam;
if (
!cached.belongsToTeam &&
cached.headWindowFull &&
fileStat.size >= cached.size &&
(await this.isCachedTeamAffinityHeadCurrent(filePath, cached))
) {
return {
belongsToTeam: false,
inspectedLineCount: 0,
matchSource: 'none',
headWindowFull: true,
indexable: false,
};
}
}
const headMetadata = await this.getTeamAffinityHeadMetadata(filePath, fileStat);
if (!headMetadata) {
return false;
return emptyResult;
}
const evaluation = this.evaluateTeamAffinityHeadMetadata(headMetadata, normalizedTeam);
const headWindowFull = evaluation.inspectedLineCount >= TEAM_AFFINITY_SCAN_LINES;
this.setTeamAffinityFileCacheEntry(cacheKey, {
mtimeMs: fileStat.mtimeMs,
size: fileStat.size,
...(fileStat.ctimeMs != null && Number.isFinite(fileStat.ctimeMs)
? { ctimeMs: fileStat.ctimeMs }
: {}),
belongsToTeam: evaluation.belongsToTeam,
headWindowFull: evaluation.inspectedLineCount >= TEAM_AFFINITY_SCAN_LINES,
inspectedLineCount: headMetadata.inspectedLineCount,
headFingerprint: headMetadata.headFingerprint,
headWindowFull,
});
return evaluation.belongsToTeam;
return {
...evaluation,
headWindowFull,
indexable: true,
};
}
private evaluateTeamAffinityHeadMetadata(
@ -1125,31 +1254,112 @@ export class TeamTranscriptProjectResolver {
for (const line of metadata.lines) {
inspectedLineCount += 1;
if (line.nestedTeamNames.has(normalizedTeam)) {
return { belongsToTeam: true, inspectedLineCount };
return { belongsToTeam: true, inspectedLineCount, matchSource: 'nested_team_name' };
}
if (
line.normalizedTextContent &&
lineMentionsNormalizedTeam(line.normalizedTextContent, normalizedTeam)
) {
return { belongsToTeam: true, inspectedLineCount };
return { belongsToTeam: true, inspectedLineCount, matchSource: 'text_team_mention' };
}
}
return { belongsToTeam: false, inspectedLineCount: metadata.inspectedLineCount };
return {
belongsToTeam: false,
inspectedLineCount: metadata.inspectedLineCount,
matchSource: 'none',
};
}
private async getTeamAffinityHeadMetadata(
filePath: string,
fileStat: { mtimeMs: number; size: number }
): Promise<TeamAffinityHeadMetadataCacheEntry | null> {
const cached = this.teamAffinityHeadMetadataCache.get(filePath);
if (cached && cached.mtimeMs === fileStat.mtimeMs && cached.size === fileStat.size) {
return cached;
private isPersistentAffinityIndexEnabled(): boolean {
return process.env.CLAUDE_TEAM_TRANSCRIPT_AFFINITY_INDEX !== '0';
}
private async loadTeamTranscriptAffinityIndex(
teamName: string,
projectId: string
): Promise<PersistedTeamTranscriptAffinityIndex | null> {
try {
return await this.affinityIndexStore.loadProject(teamName, projectId);
} catch (error) {
logger.debug(`Failed to load transcript affinity index: ${String(error)}`);
return null;
}
if (cached) {
this.teamAffinityHeadMetadataCache.delete(filePath);
}
private decideTeamAffinityFromIndex(
entry: PersistedTeamTranscriptAffinityEntry | undefined,
fileStat: TeamTranscriptFileStat
): boolean | null {
if (!entry) {
return null;
}
if (!this.teamTranscriptFileSignaturesMatch(entry.signature, fileStat)) {
return null;
}
return entry.verdict === 'belongs';
}
private teamTranscriptFileSignaturesMatch(
cached: { size: number; mtimeMs: number; ctimeMs?: number },
fileStat: { size: number; mtimeMs: number; ctimeMs?: number }
): boolean {
if (cached.size !== fileStat.size || cached.mtimeMs !== fileStat.mtimeMs) {
return false;
}
const cachedCtimeMs =
cached.ctimeMs != null && Number.isFinite(cached.ctimeMs) ? cached.ctimeMs : null;
const currentCtimeMs =
fileStat.ctimeMs != null && Number.isFinite(fileStat.ctimeMs) ? fileStat.ctimeMs : null;
if (cachedCtimeMs !== null || currentCtimeMs !== null) {
return cachedCtimeMs !== null && currentCtimeMs !== null && cachedCtimeMs === currentCtimeMs;
}
return true;
}
private buildTeamAffinityIndexEntry(
fileName: string,
fileStat: TeamTranscriptFileStat,
inspection: TeamAffinityInspectionResult
): PersistedTeamTranscriptAffinityEntry | null {
if (
fileName.length <= '.jsonl'.length ||
!fileName.endsWith('.jsonl') ||
fileName.includes('/') ||
fileName.includes('\\')
) {
return null;
}
const lines: TeamAffinityHeadLineMetadata[] = [];
const sessionId = fileName.slice(0, -'.jsonl'.length);
const signature: TeamTranscriptAffinityFileSignature = {
size: fileStat.size,
mtimeMs: fileStat.mtimeMs,
...(fileStat.ctimeMs != null && Number.isFinite(fileStat.ctimeMs)
? { ctimeMs: fileStat.ctimeMs }
: {}),
};
return {
fileName,
sessionId,
signature,
verdict: inspection.belongsToTeam ? 'belongs' : 'does_not_belong',
headWindowFull: inspection.headWindowFull,
inspectedLineCount: inspection.inspectedLineCount,
matchSource: inspection.matchSource,
writtenAt: new Date().toISOString(),
};
}
private async isCachedTeamAffinityHeadCurrent(
filePath: string,
cached: TeamAffinityFileCacheEntry
): Promise<boolean> {
if (cached.inspectedLineCount <= 0) {
return false;
}
const fingerprint = createHash('sha256');
let inspectedLineCount = 0;
const inspectHeadLine = (rawLine: string): boolean => {
const trimmed = rawLine.trim();
@ -1157,6 +1367,76 @@ export class TeamTranscriptProjectResolver {
return false;
}
inspectedLineCount += 1;
fingerprint.update(trimmed);
fingerprint.update('\n');
return inspectedLineCount >= cached.inspectedLineCount;
};
let handle: fs.FileHandle | null = null;
try {
handle = await fs.open(filePath, 'r');
const decoder = new StringDecoder('utf8');
const chunk = Buffer.allocUnsafe(TEAM_AFFINITY_READ_CHUNK_BYTES);
let pending = '';
let position = 0;
let stop = false;
while (!stop) {
const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
if (bytesRead <= 0) {
pending += decoder.end();
if (pending.length > 0) {
inspectHeadLine(pending);
}
break;
}
position += bytesRead;
pending += decoder.write(chunk.subarray(0, bytesRead));
let newlineIndex = pending.indexOf('\n');
while (newlineIndex !== -1) {
const line = pending.slice(0, newlineIndex);
pending = pending.slice(newlineIndex + 1);
if (inspectHeadLine(line)) {
stop = true;
break;
}
newlineIndex = pending.indexOf('\n');
}
}
} catch {
return false;
} finally {
await handle?.close().catch(() => undefined);
}
return (
inspectedLineCount === cached.inspectedLineCount &&
fingerprint.digest('hex') === cached.headFingerprint
);
}
private async getTeamAffinityHeadMetadata(
filePath: string,
fileStat: { mtimeMs: number; size: number; ctimeMs?: number }
): Promise<TeamAffinityHeadMetadataCacheEntry | null> {
const cached = this.teamAffinityHeadMetadataCache.get(filePath);
if (cached && this.teamTranscriptFileSignaturesMatch(cached, fileStat)) {
return cached;
}
if (cached) {
this.teamAffinityHeadMetadataCache.delete(filePath);
}
const lines: TeamAffinityHeadLineMetadata[] = [];
const fingerprint = createHash('sha256');
let inspectedLineCount = 0;
const inspectHeadLine = (rawLine: string): boolean => {
const trimmed = rawLine.trim();
if (!trimmed) {
return false;
}
inspectedLineCount += 1;
fingerprint.update(trimmed);
fingerprint.update('\n');
lines.push(parseTeamAffinityHeadLine(trimmed));
return inspectedLineCount >= TEAM_AFFINITY_SCAN_LINES;
};
@ -1201,7 +1481,11 @@ export class TeamTranscriptProjectResolver {
const entry = {
mtimeMs: fileStat.mtimeMs,
size: fileStat.size,
...(fileStat.ctimeMs != null && Number.isFinite(fileStat.ctimeMs)
? { ctimeMs: fileStat.ctimeMs }
: {}),
inspectedLineCount,
headFingerprint: fingerprint.digest('hex'),
lines,
};
this.setTeamAffinityHeadMetadataCacheEntry(filePath, entry);

View file

@ -0,0 +1,170 @@
import { atomicWriteAsync } from '@main/utils/atomicWrite';
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs/promises';
import * as path from 'path';
import {
normalizeTeamTranscriptAffinityIndex,
toTeamTranscriptAffinityIndex,
} from './teamTranscriptAffinityIndexSchema';
import {
type PersistedTeamTranscriptAffinityEntry,
type PersistedTeamTranscriptAffinityIndex,
TEAM_TRANSCRIPT_AFFINITY_INDEX_MAX_ENTRIES_PER_PROJECT,
TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION,
type TeamTranscriptAffinityIndexStore,
} from './teamTranscriptAffinityIndexTypes';
const logger = createLogger('Service:JsonTeamTranscriptAffinityIndexStore');
const READ_TIMEOUT_MS = 5_000;
function encodeFileSegment(value: string): string {
return encodeURIComponent(value);
}
function sortEntriesByFreshness(
entries: PersistedTeamTranscriptAffinityEntry[]
): PersistedTeamTranscriptAffinityEntry[] {
return [...entries].sort((left, right) => {
const rightWrittenAt = Date.parse(right.writtenAt);
const leftWrittenAt = Date.parse(left.writtenAt);
return rightWrittenAt - leftWrittenAt || right.fileName.localeCompare(left.fileName);
});
}
export class JsonTeamTranscriptAffinityIndexStore implements TeamTranscriptAffinityIndexStore {
private readonly writeChains = new Map<string, Promise<void>>();
constructor(private readonly options: { maxEntriesPerProject?: number } = {}) {}
private get maxEntriesPerProject(): number {
return Math.max(
1,
this.options.maxEntriesPerProject ?? TEAM_TRANSCRIPT_AFFINITY_INDEX_MAX_ENTRIES_PER_PROJECT
);
}
private filePath(teamName: string, projectId: string): string {
return path.join(
getTeamsBasePath(),
teamName,
'cache',
'transcript-affinity',
`${encodeFileSegment(projectId)}.json`
);
}
private writeChainKey(teamName: string, projectId: string): string {
return `${teamName}\0${projectId}`;
}
private async readIndex(
teamName: string,
projectId: string
): Promise<PersistedTeamTranscriptAffinityIndex | null> {
const filePath = this.filePath(teamName, projectId);
let content: string;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), READ_TIMEOUT_MS);
try {
content = await fs.readFile(filePath, {
encoding: 'utf8',
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.debug(`Failed to read transcript affinity index ${filePath}: ${String(error)}`);
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(content) as unknown;
} catch (error) {
logger.debug(`Corrupted transcript affinity index ${filePath}: ${String(error)}`);
await fs.unlink(filePath).catch(() => undefined);
return null;
}
const normalized = normalizeTeamTranscriptAffinityIndex(parsed);
if (!normalized || normalized.teamName !== teamName || normalized.projectId !== projectId) {
await fs.unlink(filePath).catch(() => undefined);
return null;
}
return normalized;
}
async loadProject(
teamName: string,
projectId: string
): Promise<PersistedTeamTranscriptAffinityIndex | null> {
return this.readIndex(teamName, projectId);
}
async upsertProjectEntries(input: {
teamName: string;
projectId: string;
projectDir: string;
rootFileNames: ReadonlySet<string>;
entries: readonly PersistedTeamTranscriptAffinityEntry[];
}): Promise<void> {
const chainKey = this.writeChainKey(input.teamName, input.projectId);
const write = async (): Promise<void> => {
const current = await this.readIndex(input.teamName, input.projectId);
const entries = new Map<string, PersistedTeamTranscriptAffinityEntry>();
for (const [fileName, entry] of Object.entries(current?.entries ?? {})) {
if (input.rootFileNames.has(fileName)) {
entries.set(fileName, entry);
}
}
for (const entry of input.entries) {
if (input.rootFileNames.has(entry.fileName)) {
entries.set(entry.fileName, entry);
}
}
const cappedEntries = sortEntriesByFreshness([...entries.values()]).slice(
0,
this.maxEntriesPerProject
);
const next = toTeamTranscriptAffinityIndex({
version: TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION,
teamName: input.teamName,
projectId: input.projectId,
projectDir: input.projectDir,
writtenAt: new Date().toISOString(),
entries: Object.fromEntries(cappedEntries.map((entry) => [entry.fileName, entry])),
});
await atomicWriteAsync(
this.filePath(input.teamName, input.projectId),
`${JSON.stringify(next, null, 2)}\n`
);
};
const previous = this.writeChains.get(chainKey) ?? Promise.resolve();
const next = previous
.catch(() => undefined)
.then(write)
.finally(() => {
if (this.writeChains.get(chainKey) === next) {
this.writeChains.delete(chainKey);
}
});
this.writeChains.set(chainKey, next);
await next;
}
}

View file

@ -0,0 +1,162 @@
import {
type PersistedTeamTranscriptAffinityEntry,
type PersistedTeamTranscriptAffinityIndex,
TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION,
type TeamTranscriptAffinityFileSignature,
type TeamTranscriptAffinityMatchSource,
type TeamTranscriptAffinityVerdict,
} from './teamTranscriptAffinityIndexTypes';
function isIsoString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0 && Number.isFinite(Date.parse(value));
}
function isFiniteNonNegativeNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
}
function isValidFileName(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length > '.jsonl'.length &&
value.endsWith('.jsonl') &&
!value.includes('/') &&
!value.includes('\\')
);
}
function sessionIdFromFileName(fileName: string): string {
return fileName.slice(0, -'.jsonl'.length);
}
function normalizeVerdict(value: unknown): TeamTranscriptAffinityVerdict | null {
return value === 'belongs' || value === 'does_not_belong' ? value : null;
}
function normalizeMatchSource(value: unknown): TeamTranscriptAffinityMatchSource | null {
return value === 'nested_team_name' || value === 'text_team_mention' || value === 'none'
? value
: null;
}
function normalizeSignature(value: unknown): TeamTranscriptAffinityFileSignature | null {
if (!value || typeof value !== 'object') {
return null;
}
const raw = value as Record<string, unknown>;
if (!isFiniteNonNegativeNumber(raw.size) || !isFiniteNonNegativeNumber(raw.mtimeMs)) {
return null;
}
if (raw.ctimeMs != null && !isFiniteNonNegativeNumber(raw.ctimeMs)) {
return null;
}
return {
size: raw.size,
mtimeMs: raw.mtimeMs,
...(raw.ctimeMs != null ? { ctimeMs: raw.ctimeMs } : {}),
};
}
export function normalizeTeamTranscriptAffinityEntry(
fileName: string,
value: unknown
): PersistedTeamTranscriptAffinityEntry | null {
if (!isValidFileName(fileName) || !value || typeof value !== 'object') {
return null;
}
const raw = value as Record<string, unknown>;
const verdict = normalizeVerdict(raw.verdict);
const signature = normalizeSignature(raw.signature);
const matchSource = normalizeMatchSource(raw.matchSource);
const expectedSessionId = sessionIdFromFileName(fileName);
if (
raw.fileName !== fileName ||
raw.sessionId !== expectedSessionId ||
!signature ||
!verdict ||
typeof raw.headWindowFull !== 'boolean' ||
!Number.isInteger(raw.inspectedLineCount) ||
!isFiniteNonNegativeNumber(raw.inspectedLineCount) ||
!matchSource ||
!isIsoString(raw.writtenAt)
) {
return null;
}
return {
fileName,
sessionId: expectedSessionId,
signature,
verdict,
headWindowFull: raw.headWindowFull,
inspectedLineCount: raw.inspectedLineCount,
matchSource,
writtenAt: raw.writtenAt,
};
}
export function normalizeTeamTranscriptAffinityIndex(
value: unknown
): PersistedTeamTranscriptAffinityIndex | null {
if (!value || typeof value !== 'object') {
return null;
}
const raw = value as Record<string, unknown>;
if (
raw.version !== TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION ||
typeof raw.teamName !== 'string' ||
raw.teamName.length === 0 ||
typeof raw.projectId !== 'string' ||
raw.projectId.length === 0 ||
typeof raw.projectDir !== 'string' ||
raw.projectDir.length === 0 ||
!isIsoString(raw.writtenAt) ||
!raw.entries ||
typeof raw.entries !== 'object'
) {
return null;
}
const entries: Record<string, PersistedTeamTranscriptAffinityEntry> = {};
for (const [fileName, entry] of Object.entries(raw.entries as Record<string, unknown>)) {
const normalized = normalizeTeamTranscriptAffinityEntry(fileName, entry);
if (normalized) {
entries[fileName] = normalized;
}
}
return {
version: TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION,
teamName: raw.teamName,
projectId: raw.projectId,
projectDir: raw.projectDir,
writtenAt: raw.writtenAt,
entries,
};
}
export function toTeamTranscriptAffinityIndex(
value: PersistedTeamTranscriptAffinityIndex
): PersistedTeamTranscriptAffinityIndex {
const entries: Record<string, PersistedTeamTranscriptAffinityEntry> = {};
for (const [fileName, entry] of Object.entries(value.entries)) {
const normalized = normalizeTeamTranscriptAffinityEntry(fileName, entry);
if (normalized) {
entries[fileName] = normalized;
}
}
return {
version: TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION,
teamName: value.teamName,
projectId: value.projectId,
projectDir: value.projectDir,
writtenAt: value.writtenAt,
entries,
};
}

View file

@ -0,0 +1,47 @@
export const TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION = 1;
export const TEAM_TRANSCRIPT_AFFINITY_INDEX_MAX_ENTRIES_PER_PROJECT = 20_000;
export type TeamTranscriptAffinityVerdict = 'belongs' | 'does_not_belong';
export type TeamTranscriptAffinityMatchSource = 'nested_team_name' | 'text_team_mention' | 'none';
export interface TeamTranscriptAffinityFileSignature {
size: number;
mtimeMs: number;
ctimeMs?: number;
}
export interface PersistedTeamTranscriptAffinityEntry {
fileName: string;
sessionId: string;
signature: TeamTranscriptAffinityFileSignature;
verdict: TeamTranscriptAffinityVerdict;
headWindowFull: boolean;
inspectedLineCount: number;
matchSource: TeamTranscriptAffinityMatchSource;
writtenAt: string;
}
export interface PersistedTeamTranscriptAffinityIndex {
version: typeof TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION;
teamName: string;
projectId: string;
projectDir: string;
writtenAt: string;
entries: Record<string, PersistedTeamTranscriptAffinityEntry>;
}
export interface TeamTranscriptAffinityIndexStore {
loadProject(
teamName: string,
projectId: string
): Promise<PersistedTeamTranscriptAffinityIndex | null>;
upsertProjectEntries(input: {
teamName: string;
projectId: string;
projectDir: string;
rootFileNames: ReadonlySet<string>;
entries: readonly PersistedTeamTranscriptAffinityEntry[];
}): Promise<void>;
}

View file

@ -0,0 +1,153 @@
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { JsonTeamTranscriptAffinityIndexStore } from '../../../../src/main/services/team/cache/JsonTeamTranscriptAffinityIndexStore';
import {
type PersistedTeamTranscriptAffinityEntry,
TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION,
} from '../../../../src/main/services/team/cache/teamTranscriptAffinityIndexTypes';
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
describe('JsonTeamTranscriptAffinityIndexStore', () => {
let tmpDir: string | null = null;
afterEach(async () => {
setClaudeBasePathOverride(null);
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
tmpDir = null;
}
});
async function setupClaudeRoot(): Promise<string> {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-transcript-affinity-index-'));
setClaudeBasePathOverride(tmpDir);
await fs.mkdir(path.join(tmpDir, 'teams'), { recursive: true });
return tmpDir;
}
function indexPath(teamName: string, projectId: string): string {
return path.join(
tmpDir!,
'teams',
teamName,
'cache',
'transcript-affinity',
`${encodeURIComponent(projectId)}.json`
);
}
function entry(
fileName: string,
overrides: Partial<PersistedTeamTranscriptAffinityEntry> = {}
): PersistedTeamTranscriptAffinityEntry {
return {
fileName,
sessionId: fileName.slice(0, -'.jsonl'.length),
signature: { size: 100, mtimeMs: 200, ctimeMs: 300 },
verdict: 'belongs',
headWindowFull: false,
inspectedLineCount: 1,
matchSource: 'text_team_mention',
writtenAt: '2026-05-30T10:00:00.000Z',
...overrides,
};
}
it('returns null for a missing index', async () => {
await setupClaudeRoot();
const store = new JsonTeamTranscriptAffinityIndexStore();
await expect(store.loadProject('team-a', 'project-a')).resolves.toBeNull();
});
it('upserts entries, prunes deleted root files, and caps by newest writtenAt', async () => {
await setupClaudeRoot();
const store = new JsonTeamTranscriptAffinityIndexStore({ maxEntriesPerProject: 2 });
await store.upsertProjectEntries({
teamName: 'team-a',
projectId: 'project-a',
projectDir: '/repo/a',
rootFileNames: new Set(['a.jsonl', 'b.jsonl']),
entries: [
entry('a.jsonl', { writtenAt: '2026-05-30T10:00:00.000Z' }),
entry('b.jsonl', { writtenAt: '2026-05-30T10:01:00.000Z' }),
],
});
await store.upsertProjectEntries({
teamName: 'team-a',
projectId: 'project-a',
projectDir: '/repo/a',
rootFileNames: new Set(['b.jsonl', 'c.jsonl', 'd.jsonl']),
entries: [
entry('c.jsonl', { writtenAt: '2026-05-30T10:02:00.000Z' }),
entry('d.jsonl', { writtenAt: '2026-05-30T10:03:00.000Z' }),
],
});
const loaded = await store.loadProject('team-a', 'project-a');
expect(Object.keys(loaded?.entries ?? {}).sort()).toEqual(['c.jsonl', 'd.jsonl']);
expect(loaded?.entries['a.jsonl']).toBeUndefined();
expect(loaded?.projectDir).toBe('/repo/a');
});
it('deletes corrupt or wrong-schema index files without throwing', async () => {
await setupClaudeRoot();
const store = new JsonTeamTranscriptAffinityIndexStore();
const filePath = indexPath('team-a', 'project-a');
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, '{not-json', 'utf8');
await expect(store.loadProject('team-a', 'project-a')).resolves.toBeNull();
await expect(fs.access(filePath)).rejects.toMatchObject({ code: 'ENOENT' });
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(
filePath,
JSON.stringify({
version: TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION + 1,
teamName: 'team-a',
projectId: 'project-a',
projectDir: '/repo/a',
writtenAt: '2026-05-30T10:00:00.000Z',
entries: {},
}),
'utf8'
);
await expect(store.loadProject('team-a', 'project-a')).resolves.toBeNull();
await expect(fs.access(filePath)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('skips invalid entries while preserving valid entries in the same index', async () => {
await setupClaudeRoot();
const store = new JsonTeamTranscriptAffinityIndexStore();
const filePath = indexPath('team-a', 'project-a');
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(
filePath,
JSON.stringify({
version: TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION,
teamName: 'team-a',
projectId: 'project-a',
projectDir: '/repo/a',
writtenAt: '2026-05-30T10:00:00.000Z',
entries: {
'good.jsonl': entry('good.jsonl'),
'../bad.jsonl': entry('../bad.jsonl'),
'wrong-session.jsonl': entry('wrong-session.jsonl', { sessionId: 'different' }),
},
}),
'utf8'
);
const loaded = await store.loadProject('team-a', 'project-a');
expect(Object.keys(loaded?.entries ?? {})).toEqual(['good.jsonl']);
});
});

View file

@ -6,13 +6,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { TeamTranscriptProjectResolver } from '../../../../src/main/services/team/TeamTranscriptProjectResolver';
import { encodePath, setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
import type { TeamTranscriptAffinityIndexStore } from '../../../../src/main/services/team/cache/teamTranscriptAffinityIndexTypes';
import type { TeamConfig } from '../../../../src/shared/types/team';
describe('TeamTranscriptProjectResolver', () => {
let tmpDir: string | null = null;
const originalAffinityIndexEnv = process.env.CLAUDE_TEAM_TRANSCRIPT_AFFINITY_INDEX;
afterEach(async () => {
setClaudeBasePathOverride(null);
if (originalAffinityIndexEnv == null) {
delete process.env.CLAUDE_TEAM_TRANSCRIPT_AFFINITY_INDEX;
} else {
process.env.CLAUDE_TEAM_TRANSCRIPT_AFFINITY_INDEX = originalAffinityIndexEnv;
}
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
tmpDir = null;
@ -38,6 +45,62 @@ describe('TeamTranscriptProjectResolver', () => {
return JSON.parse(raw) as TeamConfig;
}
function affinityIndexPath(teamName: string, projectId: string): string {
return path.join(
tmpDir!,
'teams',
teamName,
'cache',
'transcript-affinity',
`${encodeURIComponent(projectId)}.json`
);
}
async function readAffinityIndex(teamName: string, projectId: string): Promise<{
entries: Record<
string,
{ signature: { size: number; mtimeMs: number; ctimeMs?: number }; verdict: string }
>;
}> {
const raw = await fs.readFile(affinityIndexPath(teamName, projectId), 'utf8');
return JSON.parse(raw) as {
entries: Record<
string,
{ signature: { size: number; mtimeMs: number; ctimeMs?: number }; verdict: string }
>;
};
}
async function writeAffinityIndex(
teamName: string,
projectId: string,
value: unknown
): Promise<void> {
const filePath = affinityIndexPath(teamName, projectId);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, JSON.stringify(value, null, 2), 'utf8');
}
function sameByteLengthNoTeamTranscript(targetBytes: number): string {
for (let length = 0; length < targetBytes; length += 1) {
const candidate = `${JSON.stringify({
type: 'assistant',
message: {
role: 'assistant',
content: 'x'.repeat(length),
},
})}\n`;
const candidateBytes = Buffer.byteLength(candidate, 'utf8');
if (candidateBytes === targetBytes) {
return candidate;
}
if (candidateBytes > targetBytes) {
break;
}
}
throw new Error(`Could not create same-byte transcript for ${targetBytes} bytes`);
}
async function createSessionFile(
projectPath: string,
sessionId: string,
@ -624,6 +687,325 @@ describe('TeamTranscriptProjectResolver', () => {
expect(fullContext?.sessionIds).toContain('old-member-session');
});
it('uses a persistent exact affinity index without re-reading matching root transcript heads', async () => {
await setupClaudeRoot();
const teamName = 'persistent-index-team';
const projectPath = '/Users/test/persistent-index';
const sessionId = 'lead-indexed';
await createTeamAwareSessionFile(projectPath, sessionId, teamName, 'text');
await writeTeamConfig(teamName, {
name: 'Persistent Index Team',
projectPath,
members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }],
});
const firstResolver = new TeamTranscriptProjectResolver();
const firstContext = await firstResolver.getContext(teamName, { forceRefresh: true });
expect(firstContext?.sessionIds).toContain(sessionId);
type ResolverScanProbe = {
getTeamAffinityHeadMetadata: (...args: unknown[]) => Promise<unknown>;
};
const secondResolver = new TeamTranscriptProjectResolver();
const scanSpy = vi.spyOn(
secondResolver as unknown as ResolverScanProbe,
'getTeamAffinityHeadMetadata'
);
scanSpy.mockRejectedValue(new Error('persistent index should bypass head scan'));
const secondContext = await secondResolver.getContext(teamName, { forceRefresh: true });
expect(secondContext?.sessionIds).toContain(sessionId);
expect(scanSpy).not.toHaveBeenCalled();
scanSpy.mockRestore();
});
it('falls back to a fresh head scan when a persistent index signature is stale', async () => {
await setupClaudeRoot();
const teamName = 'stale-persistent-index-team';
const projectPath = '/Users/test/stale-persistent-index';
const sessionId = 'stale-indexed';
const created = await createTeamAwareSessionFile(projectPath, sessionId, teamName, 'text');
await writeTeamConfig(teamName, {
name: 'Stale Persistent Index Team',
projectPath,
members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }],
});
const firstResolver = new TeamTranscriptProjectResolver();
const firstContext = await firstResolver.getContext(teamName, { forceRefresh: true });
expect(firstContext?.sessionIds).toContain(sessionId);
await fs.writeFile(
created.jsonlPath,
`${JSON.stringify({
type: 'assistant',
message: { role: 'assistant', content: 'no team mention here' },
})}\n`,
'utf8'
);
const updatedAt = new Date(Date.now() + 5_000);
await fs.utimes(created.jsonlPath, updatedAt, updatedAt);
const secondResolver = new TeamTranscriptProjectResolver();
const secondContext = await secondResolver.getContext(teamName, { forceRefresh: true });
expect(secondContext?.sessionIds).not.toContain(sessionId);
});
it('treats ctime mismatch as stale even when persistent index size and mtime still match', async () => {
await setupClaudeRoot();
const teamName = 'ctime-persistent-index-team';
const projectPath = '/Users/test/ctime-persistent-index';
const projectId = encodePath(projectPath);
const sessionId = 'ctime-indexed';
const created = await createTeamAwareSessionFile(projectPath, sessionId, teamName, 'text');
const stableTime = new Date('2026-05-30T10:00:00.000Z');
await fs.utimes(created.jsonlPath, stableTime, stableTime);
await writeTeamConfig(teamName, {
name: 'Ctime Persistent Index Team',
projectPath,
members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }],
});
const firstResolver = new TeamTranscriptProjectResolver();
const firstContext = await firstResolver.getContext(teamName, { forceRefresh: true });
expect(firstContext?.sessionIds).toContain(sessionId);
const indexedBefore = await readAffinityIndex(teamName, projectId);
const originalSize = indexedBefore.entries[`${sessionId}.jsonl`].signature.size;
await fs.writeFile(created.jsonlPath, sameByteLengthNoTeamTranscript(originalSize), 'utf8');
await fs.utimes(created.jsonlPath, stableTime, stableTime);
const currentStat = await fs.stat(created.jsonlPath);
expect(currentStat.size).toBe(originalSize);
expect(currentStat.mtimeMs).toBe(indexedBefore.entries[`${sessionId}.jsonl`].signature.mtimeMs);
expect(currentStat.ctimeMs).not.toBe(
indexedBefore.entries[`${sessionId}.jsonl`].signature.ctimeMs
);
const secondResolver = new TeamTranscriptProjectResolver();
const secondContext = await secondResolver.getContext(teamName, { forceRefresh: true });
expect(secondContext?.sessionIds).not.toContain(sessionId);
});
it('treats a persistent index entry without ctime as stale when the file stat has ctime', async () => {
await setupClaudeRoot();
const teamName = 'missing-ctime-persistent-index-team';
const projectPath = '/Users/test/missing-ctime-persistent-index';
const projectId = encodePath(projectPath);
const sessionId = 'missing-ctime-indexed';
await createTeamAwareSessionFile(projectPath, sessionId, teamName, 'text');
await writeTeamConfig(teamName, {
name: 'Missing Ctime Persistent Index Team',
projectPath,
members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }],
});
const firstResolver = new TeamTranscriptProjectResolver();
const firstContext = await firstResolver.getContext(teamName, { forceRefresh: true });
expect(firstContext?.sessionIds).toContain(sessionId);
const index = await readAffinityIndex(teamName, projectId);
delete index.entries[`${sessionId}.jsonl`].signature.ctimeMs;
await writeAffinityIndex(teamName, projectId, index);
type ResolverScanProbe = {
getTeamAffinityHeadMetadata: (...args: unknown[]) => Promise<unknown>;
};
const secondResolver = new TeamTranscriptProjectResolver();
const scanSpy = vi.spyOn(
secondResolver as unknown as ResolverScanProbe,
'getTeamAffinityHeadMetadata'
);
const secondContext = await secondResolver.getContext(teamName, { forceRefresh: true });
expect(secondContext?.sessionIds).toContain(sessionId);
expect(scanSpy).toHaveBeenCalled();
scanSpy.mockRestore();
});
it('reuses exact persistent negatives but rescans after a short transcript grows', async () => {
await setupClaudeRoot();
const teamName = 'negative-persistent-index-team';
const projectPath = '/Users/test/negative-persistent-index';
const sessionId = 'short-negative';
const projectDir = path.join(tmpDir!, 'projects', encodePath(projectPath));
const jsonlPath = path.join(projectDir, `${sessionId}.jsonl`);
await fs.mkdir(projectDir, { recursive: true });
await fs.writeFile(
jsonlPath,
`${[0, 1, 2]
.map((i) =>
JSON.stringify({ type: 'user', message: { role: 'user', content: `noise ${i}` } })
)
.join('\n')}\n`,
'utf8'
);
await writeTeamConfig(teamName, {
name: 'Negative Persistent Index Team',
projectPath,
members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }],
});
const firstResolver = new TeamTranscriptProjectResolver();
const firstContext = await firstResolver.getContext(teamName, { forceRefresh: true });
expect(firstContext?.sessionIds).not.toContain(sessionId);
type ResolverScanProbe = {
getTeamAffinityHeadMetadata: (...args: unknown[]) => Promise<unknown>;
};
const secondResolver = new TeamTranscriptProjectResolver();
const scanSpy = vi.spyOn(
secondResolver as unknown as ResolverScanProbe,
'getTeamAffinityHeadMetadata'
);
scanSpy.mockRejectedValue(new Error('persistent negative should bypass head scan'));
const secondContext = await secondResolver.getContext(teamName, { forceRefresh: true });
expect(secondContext?.sessionIds).not.toContain(sessionId);
expect(scanSpy).not.toHaveBeenCalled();
scanSpy.mockRestore();
await fs.appendFile(
jsonlPath,
`${JSON.stringify({
type: 'user',
message: {
role: 'user',
content: [{ type: 'text', text: `Current team context:\n- Team name: ${teamName}` }],
},
})}\n`,
'utf8'
);
const updatedAt = new Date(Date.now() + 5_000);
await fs.utimes(jsonlPath, updatedAt, updatedAt);
const thirdResolver = new TeamTranscriptProjectResolver();
const thirdContext = await thirdResolver.getContext(teamName, { forceRefresh: true });
expect(thirdContext?.sessionIds).toContain(sessionId);
});
it('prunes persistent affinity entries for deleted root transcripts without requiring a new scan', async () => {
await setupClaudeRoot();
const teamName = 'prune-persistent-index-team';
const projectPath = '/Users/test/prune-persistent-index';
const projectId = encodePath(projectPath);
const keptSessionId = 'kept-session';
const deletedSessionId = 'deleted-session';
const kept = await createTeamAwareSessionFile(projectPath, keptSessionId, teamName, 'text');
const deleted = await createTeamAwareSessionFile(
projectPath,
deletedSessionId,
teamName,
'text'
);
await writeTeamConfig(teamName, {
name: 'Prune Persistent Index Team',
projectPath,
members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }],
});
const firstResolver = new TeamTranscriptProjectResolver();
await firstResolver.getContext(teamName, { forceRefresh: true });
const indexedBefore = await readAffinityIndex(teamName, projectId);
expect(Object.keys(indexedBefore.entries).sort()).toEqual([
`${deletedSessionId}.jsonl`,
`${keptSessionId}.jsonl`,
]);
await fs.rm(deleted.jsonlPath);
type ResolverScanProbe = {
getTeamAffinityHeadMetadata: (...args: unknown[]) => Promise<unknown>;
};
const secondResolver = new TeamTranscriptProjectResolver();
const scanSpy = vi.spyOn(
secondResolver as unknown as ResolverScanProbe,
'getTeamAffinityHeadMetadata'
);
scanSpy.mockRejectedValue(new Error('remaining exact index hit should bypass head scan'));
const secondContext = await secondResolver.getContext(teamName, { forceRefresh: true });
const indexedAfter = await readAffinityIndex(teamName, projectId);
expect(secondContext?.sessionIds).toContain(keptSessionId);
expect(secondContext?.sessionIds).not.toContain(deletedSessionId);
expect(scanSpy).not.toHaveBeenCalled();
expect(Object.keys(indexedAfter.entries)).toEqual([`${keptSessionId}.jsonl`]);
await fs.access(kept.jsonlPath);
scanSpy.mockRestore();
});
it('keeps discovering sessions when the persistent affinity store load or write fails', async () => {
await setupClaudeRoot();
const teamName = 'failing-store-index-team';
const projectPath = '/Users/test/failing-store-index';
const sessionId = 'failing-store-session';
await createTeamAwareSessionFile(projectPath, sessionId, teamName, 'text');
await writeTeamConfig(teamName, {
name: 'Failing Store Index Team',
projectPath,
members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }],
});
const store: TeamTranscriptAffinityIndexStore = {
loadProject: vi.fn(async () => {
throw new Error('load failed');
}),
upsertProjectEntries: vi.fn(async () => {
throw new Error('write failed');
}),
};
const resolver = new TeamTranscriptProjectResolver(undefined, store);
const context = await resolver.getContext(teamName, { forceRefresh: true });
expect(context?.sessionIds).toContain(sessionId);
expect(store.loadProject).toHaveBeenCalled();
expect(store.upsertProjectEntries).toHaveBeenCalled();
});
it('does not read or write the persistent affinity index when the kill switch is disabled', async () => {
await setupClaudeRoot();
process.env.CLAUDE_TEAM_TRANSCRIPT_AFFINITY_INDEX = '0';
const teamName = 'kill-switch-index-team';
const projectPath = '/Users/test/kill-switch-index';
const sessionId = 'kill-switch-session';
await createTeamAwareSessionFile(projectPath, sessionId, teamName, 'text');
await writeTeamConfig(teamName, {
name: 'Kill Switch Index Team',
projectPath,
leadSessionId: sessionId,
members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }],
});
const store: TeamTranscriptAffinityIndexStore = {
loadProject: vi.fn(async () => {
throw new Error('disabled index should not load');
}),
upsertProjectEntries: vi.fn(async () => undefined),
};
const resolver = new TeamTranscriptProjectResolver(undefined, store);
const context = await resolver.getContext(teamName, { forceRefresh: true });
expect(context?.sessionIds).toContain(sessionId);
expect(store.loadProject).not.toHaveBeenCalled();
expect(store.upsertProjectEntries).not.toHaveBeenCalled();
});
// Regression for the launch hot path: non-matching transcripts must not be
// re-streamed + re-parsed on every bootstrap poll. A negative verdict decided from
// a FULL head window (>= 40 inspected lines) is durable while the file only grows,
@ -633,20 +1015,25 @@ describe('TeamTranscriptProjectResolver', () => {
type AffinityCacheEntry = {
mtimeMs: number;
size: number;
ctimeMs?: number;
belongsToTeam: boolean;
inspectedLineCount: number;
headFingerprint: string;
headWindowFull: boolean;
};
type HeadMetadataCacheEntry = {
mtimeMs: number;
size: number;
ctimeMs?: number;
inspectedLineCount: number;
headFingerprint: string;
lines: unknown[];
};
type ResolverProbe = {
fileBelongsToTeam: (
filePath: string,
teamName: string,
precomputedStat?: { mtimeMs: number; size: number; isFile: () => boolean }
precomputedStat?: { mtimeMs: number; size: number; ctimeMs?: number; isFile: () => boolean }
) => Promise<boolean>;
buildTeamAffinityFileCacheKey: (filePath: string, normalizedTeam: string) => string;
teamAffinityFileCache: Map<string, AffinityCacheEntry>;
@ -728,6 +1115,89 @@ describe('TeamTranscriptProjectResolver', () => {
expect(second!.size).toBeGreaterThan(sizeAfterFirst); // re-scanned + re-cached
});
it('does not reuse an in-memory positive growth shortcut after the cached head is rewritten', async () => {
await setupClaudeRoot();
const resolver = new TeamTranscriptProjectResolver() as unknown as ResolverProbe;
const team = 'rewrite-positive-team';
const projectDir = path.join(tmpDir!, 'projects', encodePath('/repo/rewrite-positive'));
await fs.mkdir(projectDir, { recursive: true });
const jsonlPath = path.join(projectDir, 'rewrite-positive.jsonl');
await fs.writeFile(jsonlPath, `${teamTextLine(team)}\n`, 'utf8');
expect(await resolver.fileBelongsToTeam(jsonlPath, team)).toBe(true);
const key = resolver.buildTeamAffinityFileCacheKey(jsonlPath, team.toLowerCase());
const first = resolver.teamAffinityFileCache.get(key);
expect(first?.belongsToTeam).toBe(true);
const replacement = `${JSON.stringify({
type: 'assistant',
message: { role: 'assistant', content: 'x'.repeat(first!.size + 100) },
})}\n`;
expect(Buffer.byteLength(replacement, 'utf8')).toBeGreaterThanOrEqual(first!.size);
await fs.writeFile(jsonlPath, replacement, 'utf8');
expect(await resolver.fileBelongsToTeam(jsonlPath, team)).toBe(false);
expect(resolver.teamAffinityFileCache.get(key)?.belongsToTeam).toBe(false);
});
it('does not reuse an in-memory full-head negative shortcut after the cached head is rewritten', async () => {
await setupClaudeRoot();
const resolver = new TeamTranscriptProjectResolver() as unknown as ResolverProbe;
const team = 'rewrite-negative-team';
const projectDir = path.join(tmpDir!, 'projects', encodePath('/repo/rewrite-negative'));
await fs.mkdir(projectDir, { recursive: true });
const jsonlPath = path.join(projectDir, 'rewrite-negative.jsonl');
const originalLines = Array.from({ length: 45 }, (_, i) => noiseLine(i));
await fs.writeFile(jsonlPath, `${originalLines.join('\n')}\n`, 'utf8');
expect(await resolver.fileBelongsToTeam(jsonlPath, team)).toBe(false);
const key = resolver.buildTeamAffinityFileCacheKey(jsonlPath, team.toLowerCase());
const first = resolver.teamAffinityFileCache.get(key);
expect(first?.headWindowFull).toBe(true);
const rewrittenLines = [teamTextLine(team), ...originalLines.slice(1)];
let replacement = `${rewrittenLines.join('\n')}\n`;
if (Buffer.byteLength(replacement, 'utf8') < first!.size) {
replacement += `${noiseLine(999)}\n`;
}
expect(Buffer.byteLength(replacement, 'utf8')).toBeGreaterThanOrEqual(first!.size);
await fs.writeFile(jsonlPath, replacement, 'utf8');
expect(await resolver.fileBelongsToTeam(jsonlPath, team)).toBe(true);
expect(resolver.teamAffinityFileCache.get(key)?.belongsToTeam).toBe(true);
});
it('does not reuse in-memory exact caches after a same-size rewrite with restored mtime', async () => {
await setupClaudeRoot();
const resolver = new TeamTranscriptProjectResolver() as unknown as ResolverProbe;
const team = 'same-size-rewrite-team';
const projectDir = path.join(tmpDir!, 'projects', encodePath('/repo/same-size-rewrite'));
await fs.mkdir(projectDir, { recursive: true });
const jsonlPath = path.join(projectDir, 'same-size-rewrite.jsonl');
const stableTime = new Date('2026-05-30T10:00:00.000Z');
await fs.writeFile(jsonlPath, `${teamTextLine(team)}\n`, 'utf8');
await fs.utimes(jsonlPath, stableTime, stableTime);
expect(await resolver.fileBelongsToTeam(jsonlPath, team)).toBe(true);
const key = resolver.buildTeamAffinityFileCacheKey(jsonlPath, team.toLowerCase());
const first = resolver.teamAffinityFileCache.get(key);
const firstHead = resolver.teamAffinityHeadMetadataCache.get(jsonlPath);
expect(first?.belongsToTeam).toBe(true);
expect(firstHead?.lines.length).toBeGreaterThan(0);
await new Promise((resolve) => setTimeout(resolve, 5));
await fs.writeFile(jsonlPath, sameByteLengthNoTeamTranscript(first!.size), 'utf8');
await fs.utimes(jsonlPath, stableTime, stableTime);
const rewrittenStat = await fs.stat(jsonlPath);
expect(rewrittenStat.size).toBe(first!.size);
expect(rewrittenStat.mtimeMs).toBe(first!.mtimeMs);
expect(rewrittenStat.ctimeMs).not.toBe(first!.ctimeMs);
expect(await resolver.fileBelongsToTeam(jsonlPath, team)).toBe(false);
expect(resolver.teamAffinityFileCache.get(key)?.belongsToTeam).toBe(false);
});
// Regression: when the caller already statted the file (the mtime-window filter in
// collectRootJsonlSessionIds), fileBelongsToTeam must reuse that stat rather than
// issuing a second fs.stat of the same file. Proven without mocking fs: a precomputed