perf: share bootstrap transcript tail parse across members
During launch, the bootstrap-wait loop polls each member and, per member, re-read and re-JSON.parsed the same growing transcript tail (readRecentBootstrapTranscriptOutcome was the top main-thread JS hotspot at ~21% during bootstrap, ~40% with its helpers). The same file was parsed once per member per poll. Memoize the parsed tail by (filePath, mtime, size) in a shared cache so the file is read + parsed once per change and reused across all members. The per-member filter and failure/success scan is byte-for-byte the same logic; only the redundant read + JSON.parse is removed. Cache is bounded (LRU, same cap as the outcome cache) and invalidated on mtime/size change, matching the existing outcome cache semantics. Adds a test asserting the tail is parsed once and shared while per-member outcome detection is unchanged.
This commit is contained in:
parent
5d63ecfe32
commit
35b76f1354
2 changed files with 135 additions and 29 deletions
|
|
@ -704,6 +704,19 @@ interface BootstrapTranscriptOutcomeCandidate {
|
|||
parsedAgentName: string | null;
|
||||
}
|
||||
|
||||
interface ParsedBootstrapTranscriptTailLine {
|
||||
rawTimestamp: string | null;
|
||||
timestampMs: number;
|
||||
text: string | null;
|
||||
parsedAgentName: string | null;
|
||||
}
|
||||
|
||||
interface ParsedBootstrapTranscriptTailCacheEntry {
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
lines: ParsedBootstrapTranscriptTailLine[];
|
||||
}
|
||||
|
||||
import type {
|
||||
ActiveToolCall,
|
||||
AgentActionMode,
|
||||
|
|
@ -3375,6 +3388,13 @@ export class TeamProvisioningService {
|
|||
string,
|
||||
BootstrapTranscriptOutcomeCacheEntry
|
||||
>();
|
||||
// Shared parsed-tail cache keyed by filePath (validated by mtime+size) so the
|
||||
// same growing transcript is read + JSON.parsed ONCE per change instead of once
|
||||
// per member per poll. The per-member outcome scan below is unchanged.
|
||||
private readonly parsedBootstrapTranscriptTailCache = new Map<
|
||||
string,
|
||||
ParsedBootstrapTranscriptTailCacheEntry
|
||||
>();
|
||||
private readonly bootstrapTranscriptOutcomeLookupCache = new Map<
|
||||
string,
|
||||
BootstrapTranscriptOutcomeLookupCacheEntry
|
||||
|
|
@ -30303,44 +30323,24 @@ export class TeamProvisioningService {
|
|||
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
||||
return cached.outcome;
|
||||
}
|
||||
const start = Math.max(0, stat.size - TeamProvisioningService.BOOTSTRAP_FAILURE_TAIL_BYTES);
|
||||
const buffer = Buffer.alloc(stat.size - start);
|
||||
if (buffer.length === 0) {
|
||||
return null;
|
||||
}
|
||||
await handle.read(buffer, 0, buffer.length, start);
|
||||
const lines = buffer.toString('utf8').split('\n');
|
||||
if (start > 0) {
|
||||
lines.shift();
|
||||
}
|
||||
// Parse the transcript tail once per (filePath, mtime, size) and share it
|
||||
// across members. The per-member filter/scan below is byte-for-byte the same
|
||||
// logic as before; only the redundant read + JSON.parse is now memoized.
|
||||
const parsedLines = await this.getParsedBootstrapTranscriptTail(handle, filePath, stat);
|
||||
const shouldCollectBootstrapContext = options.allowAnonymousFailure !== true;
|
||||
const bootstrapContextMembers = new Set<string>();
|
||||
const candidates: BootstrapTranscriptOutcomeCandidate[] = [];
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine?.trim();
|
||||
if (!line) continue;
|
||||
let parsed: { timestamp?: unknown } | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(line) as { timestamp?: unknown };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const timestampMs =
|
||||
typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : Number.NaN;
|
||||
for (const parsedLine of parsedLines) {
|
||||
const { timestampMs, parsedAgentName, text, rawTimestamp } = parsedLine;
|
||||
if (sinceMs != null && (!Number.isFinite(timestampMs) || timestampMs < sinceMs)) {
|
||||
continue;
|
||||
}
|
||||
const parsedAgentName =
|
||||
typeof (parsed as { agentName?: unknown }).agentName === 'string'
|
||||
? (parsed as { agentName?: string }).agentName?.trim().toLowerCase() || null
|
||||
: null;
|
||||
if (
|
||||
parsedAgentName &&
|
||||
!matchesObservedMemberNameForExpected(parsedAgentName, normalizedMemberName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const text = extractTranscriptMessageText(parsed);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -30354,9 +30354,7 @@ export class TeamProvisioningService {
|
|||
candidates.push({
|
||||
text,
|
||||
observedAt:
|
||||
typeof parsed.timestamp === 'string' && parsed.timestamp.trim().length > 0
|
||||
? parsed.timestamp.trim()
|
||||
: new Date().toISOString(),
|
||||
rawTimestamp && rawTimestamp.length > 0 ? rawTimestamp : new Date().toISOString(),
|
||||
parsedAgentName,
|
||||
});
|
||||
}
|
||||
|
|
@ -30428,6 +30426,73 @@ export class TeamProvisioningService {
|
|||
].join('\0');
|
||||
}
|
||||
|
||||
private async getParsedBootstrapTranscriptTail(
|
||||
handle: fs.promises.FileHandle,
|
||||
filePath: string,
|
||||
stat: { mtimeMs: number; size: number }
|
||||
): Promise<ParsedBootstrapTranscriptTailLine[]> {
|
||||
const cached = this.parsedBootstrapTranscriptTailCache.get(filePath);
|
||||
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
||||
return cached.lines;
|
||||
}
|
||||
const lines: ParsedBootstrapTranscriptTailLine[] = [];
|
||||
const start = Math.max(0, stat.size - TeamProvisioningService.BOOTSTRAP_FAILURE_TAIL_BYTES);
|
||||
const length = stat.size - start;
|
||||
if (length > 0) {
|
||||
const buffer = Buffer.alloc(length);
|
||||
await handle.read(buffer, 0, length, start);
|
||||
const rawLines = buffer.toString('utf8').split('\n');
|
||||
if (start > 0) {
|
||||
rawLines.shift();
|
||||
}
|
||||
for (const rawLine of rawLines) {
|
||||
const line = rawLine?.trim();
|
||||
if (!line) continue;
|
||||
let parsed: { timestamp?: unknown; agentName?: unknown } | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(line) as { timestamp?: unknown; agentName?: unknown };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const rawTimestamp =
|
||||
typeof parsed.timestamp === 'string' && parsed.timestamp.trim().length > 0
|
||||
? parsed.timestamp.trim()
|
||||
: null;
|
||||
const timestampMs =
|
||||
typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : Number.NaN;
|
||||
const parsedAgentName =
|
||||
typeof parsed.agentName === 'string'
|
||||
? parsed.agentName.trim().toLowerCase() || null
|
||||
: null;
|
||||
const text = extractTranscriptMessageText(parsed);
|
||||
lines.push({ rawTimestamp, timestampMs, text, parsedAgentName });
|
||||
}
|
||||
}
|
||||
this.setParsedBootstrapTranscriptTailCacheEntry(filePath, {
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
lines,
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
private setParsedBootstrapTranscriptTailCacheEntry(
|
||||
filePath: string,
|
||||
entry: ParsedBootstrapTranscriptTailCacheEntry
|
||||
): void {
|
||||
if (
|
||||
!this.parsedBootstrapTranscriptTailCache.has(filePath) &&
|
||||
this.parsedBootstrapTranscriptTailCache.size >=
|
||||
TeamProvisioningService.BOOTSTRAP_TRANSCRIPT_OUTCOME_CACHE_MAX_ENTRIES
|
||||
) {
|
||||
const oldestKey = this.parsedBootstrapTranscriptTailCache.keys().next().value;
|
||||
if (oldestKey) {
|
||||
this.parsedBootstrapTranscriptTailCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
this.parsedBootstrapTranscriptTailCache.set(filePath, entry);
|
||||
}
|
||||
|
||||
private setBootstrapTranscriptOutcomeCacheEntry(
|
||||
cacheKey: string,
|
||||
entry: BootstrapTranscriptOutcomeCacheEntry
|
||||
|
|
|
|||
|
|
@ -21167,6 +21167,47 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('parses a bootstrap transcript tail once and shares it across members', async () => {
|
||||
const teamName = 'zz-unit-bootstrap-transcript-shared-parse';
|
||||
const transcriptPath = path.join(tempProjectsBase, 'bootstrap-shared-parse.jsonl');
|
||||
await fsPromises.writeFile(
|
||||
transcriptPath,
|
||||
`${JSON.stringify({
|
||||
timestamp: '2026-05-24T09:25:42.904Z',
|
||||
agentName: 'alice',
|
||||
text: `member briefing for alice on team "${teamName}" (${teamName}). Ready.`,
|
||||
})}\n`,
|
||||
'utf8'
|
||||
);
|
||||
const updatedAt = new Date(Date.now() + 5_000);
|
||||
await fsPromises.utimes(transcriptPath, updatedAt, updatedAt);
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
const aliceOutcome = await privateHarness(svc).readRecentBootstrapTranscriptOutcome(
|
||||
transcriptPath,
|
||||
null,
|
||||
'alice',
|
||||
teamName
|
||||
);
|
||||
const bobOutcome = await privateHarness(svc).readRecentBootstrapTranscriptOutcome(
|
||||
transcriptPath,
|
||||
null,
|
||||
'bob',
|
||||
teamName
|
||||
);
|
||||
|
||||
// Per-member detection is unchanged: alice's briefing is a success, the same
|
||||
// line is not attributed to bob.
|
||||
expect(aliceOutcome).toMatchObject({ kind: 'success', source: 'member_briefing' });
|
||||
expect(bobOutcome).toBeNull();
|
||||
// The transcript tail is parsed once and shared: a single cache entry for the
|
||||
// file rather than one parse per member.
|
||||
expect((svc as unknown as Record<string, Map<string, unknown>>).parsedBootstrapTranscriptTailCache.size).toBe(
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
it('caches persisted bootstrap transcript outcome lookup between close polling reads', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-03T12:00:00.000Z'));
|
||||
|
|
|
|||
Loading…
Reference in a new issue