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;
|
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 {
|
import type {
|
||||||
ActiveToolCall,
|
ActiveToolCall,
|
||||||
AgentActionMode,
|
AgentActionMode,
|
||||||
|
|
@ -3375,6 +3388,13 @@ export class TeamProvisioningService {
|
||||||
string,
|
string,
|
||||||
BootstrapTranscriptOutcomeCacheEntry
|
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<
|
private readonly bootstrapTranscriptOutcomeLookupCache = new Map<
|
||||||
string,
|
string,
|
||||||
BootstrapTranscriptOutcomeLookupCacheEntry
|
BootstrapTranscriptOutcomeLookupCacheEntry
|
||||||
|
|
@ -30303,44 +30323,24 @@ export class TeamProvisioningService {
|
||||||
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
||||||
return cached.outcome;
|
return cached.outcome;
|
||||||
}
|
}
|
||||||
const start = Math.max(0, stat.size - TeamProvisioningService.BOOTSTRAP_FAILURE_TAIL_BYTES);
|
// Parse the transcript tail once per (filePath, mtime, size) and share it
|
||||||
const buffer = Buffer.alloc(stat.size - start);
|
// across members. The per-member filter/scan below is byte-for-byte the same
|
||||||
if (buffer.length === 0) {
|
// logic as before; only the redundant read + JSON.parse is now memoized.
|
||||||
return null;
|
const parsedLines = await this.getParsedBootstrapTranscriptTail(handle, filePath, stat);
|
||||||
}
|
|
||||||
await handle.read(buffer, 0, buffer.length, start);
|
|
||||||
const lines = buffer.toString('utf8').split('\n');
|
|
||||||
if (start > 0) {
|
|
||||||
lines.shift();
|
|
||||||
}
|
|
||||||
const shouldCollectBootstrapContext = options.allowAnonymousFailure !== true;
|
const shouldCollectBootstrapContext = options.allowAnonymousFailure !== true;
|
||||||
const bootstrapContextMembers = new Set<string>();
|
const bootstrapContextMembers = new Set<string>();
|
||||||
const candidates: BootstrapTranscriptOutcomeCandidate[] = [];
|
const candidates: BootstrapTranscriptOutcomeCandidate[] = [];
|
||||||
for (const rawLine of lines) {
|
for (const parsedLine of parsedLines) {
|
||||||
const line = rawLine?.trim();
|
const { timestampMs, parsedAgentName, text, rawTimestamp } = parsedLine;
|
||||||
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;
|
|
||||||
if (sinceMs != null && (!Number.isFinite(timestampMs) || timestampMs < sinceMs)) {
|
if (sinceMs != null && (!Number.isFinite(timestampMs) || timestampMs < sinceMs)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const parsedAgentName =
|
|
||||||
typeof (parsed as { agentName?: unknown }).agentName === 'string'
|
|
||||||
? (parsed as { agentName?: string }).agentName?.trim().toLowerCase() || null
|
|
||||||
: null;
|
|
||||||
if (
|
if (
|
||||||
parsedAgentName &&
|
parsedAgentName &&
|
||||||
!matchesObservedMemberNameForExpected(parsedAgentName, normalizedMemberName)
|
!matchesObservedMemberNameForExpected(parsedAgentName, normalizedMemberName)
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const text = extractTranscriptMessageText(parsed);
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -30354,9 +30354,7 @@ export class TeamProvisioningService {
|
||||||
candidates.push({
|
candidates.push({
|
||||||
text,
|
text,
|
||||||
observedAt:
|
observedAt:
|
||||||
typeof parsed.timestamp === 'string' && parsed.timestamp.trim().length > 0
|
rawTimestamp && rawTimestamp.length > 0 ? rawTimestamp : new Date().toISOString(),
|
||||||
? parsed.timestamp.trim()
|
|
||||||
: new Date().toISOString(),
|
|
||||||
parsedAgentName,
|
parsedAgentName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -30428,6 +30426,73 @@ export class TeamProvisioningService {
|
||||||
].join('\0');
|
].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(
|
private setBootstrapTranscriptOutcomeCacheEntry(
|
||||||
cacheKey: string,
|
cacheKey: string,
|
||||||
entry: BootstrapTranscriptOutcomeCacheEntry
|
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 () => {
|
it('caches persisted bootstrap transcript outcome lookup between close polling reads', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date('2026-05-03T12:00:00.000Z'));
|
vi.setSystemTime(new Date('2026-05-03T12:00:00.000Z'));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue