perf: cache bootstrap transcript outcomes
This commit is contained in:
parent
0b97985474
commit
fa242d9ff6
2 changed files with 132 additions and 2 deletions
|
|
@ -687,6 +687,12 @@ type BootstrapTranscriptOutcome =
|
||||||
reason: string;
|
reason: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface BootstrapTranscriptOutcomeCacheEntry {
|
||||||
|
mtimeMs: number;
|
||||||
|
size: number;
|
||||||
|
outcome: BootstrapTranscriptOutcome | null;
|
||||||
|
}
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ActiveToolCall,
|
ActiveToolCall,
|
||||||
AgentActionMode,
|
AgentActionMode,
|
||||||
|
|
@ -3290,6 +3296,7 @@ export class TeamProvisioningService {
|
||||||
private static readonly SAME_TEAM_PERSIST_RETRY_MS = 2_000;
|
private static readonly SAME_TEAM_PERSIST_RETRY_MS = 2_000;
|
||||||
private static readonly AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS = 2_000;
|
private static readonly AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS = 2_000;
|
||||||
private static readonly AGENT_RUNTIME_RESOURCE_HISTORY_LIMIT = 60;
|
private static readonly AGENT_RUNTIME_RESOURCE_HISTORY_LIMIT = 60;
|
||||||
|
private static readonly BOOTSTRAP_TRANSCRIPT_OUTCOME_CACHE_MAX_ENTRIES = 2_048;
|
||||||
private static readonly MAX_RUNTIME_TREE_PIDS_PER_ROOT = 64;
|
private static readonly MAX_RUNTIME_TREE_PIDS_PER_ROOT = 64;
|
||||||
private static readonly MAX_RUNTIME_USAGE_PIDS_PER_SNAPSHOT = 512;
|
private static readonly MAX_RUNTIME_USAGE_PIDS_PER_SNAPSHOT = 512;
|
||||||
private static readonly RUNTIME_PROCESS_TABLE_TIMEOUT_MS = 1_500;
|
private static readonly RUNTIME_PROCESS_TABLE_TIMEOUT_MS = 1_500;
|
||||||
|
|
@ -3350,6 +3357,10 @@ export class TeamProvisioningService {
|
||||||
string,
|
string,
|
||||||
PersistedTranscriptClaudeLogsCacheEntry
|
PersistedTranscriptClaudeLogsCacheEntry
|
||||||
>();
|
>();
|
||||||
|
private readonly bootstrapTranscriptOutcomeCache = new Map<
|
||||||
|
string,
|
||||||
|
BootstrapTranscriptOutcomeCacheEntry
|
||||||
|
>();
|
||||||
private readonly teamOpLocks = new Map<string, Promise<void>>();
|
private readonly teamOpLocks = new Map<string, Promise<void>>();
|
||||||
private readonly leadInboxRelayInFlight = new Map<string, Promise<number>>();
|
private readonly leadInboxRelayInFlight = new Map<string, Promise<number>>();
|
||||||
private readonly relayedLeadInboxMessageIds = new Map<string, Set<string>>();
|
private readonly relayedLeadInboxMessageIds = new Map<string, Set<string>>();
|
||||||
|
|
@ -30102,12 +30113,24 @@ export class TeamProvisioningService {
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const cacheKey = this.buildBootstrapTranscriptOutcomeCacheKey({
|
||||||
|
filePath,
|
||||||
|
sinceMs,
|
||||||
|
memberName: normalizedMemberName,
|
||||||
|
teamName,
|
||||||
|
allowAnonymousFailure: options.allowAnonymousFailure === true,
|
||||||
|
contextMemberNames,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
handle = await fs.promises.open(filePath, 'r');
|
handle = await fs.promises.open(filePath, 'r');
|
||||||
const stat = await handle.stat();
|
const stat = await handle.stat();
|
||||||
if (!stat.isFile() || stat.size <= 0) {
|
if (!stat.isFile() || stat.size <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const cached = this.bootstrapTranscriptOutcomeCache.get(cacheKey);
|
||||||
|
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 start = Math.max(0, stat.size - TeamProvisioningService.BOOTSTRAP_FAILURE_TAIL_BYTES);
|
||||||
const buffer = Buffer.alloc(stat.size - start);
|
const buffer = Buffer.alloc(stat.size - start);
|
||||||
if (buffer.length === 0) {
|
if (buffer.length === 0) {
|
||||||
|
|
@ -30155,6 +30178,7 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
const hasUnambiguousMatchingBootstrapContext =
|
const hasUnambiguousMatchingBootstrapContext =
|
||||||
bootstrapContextMembers.size === 1 && bootstrapContextMembers.has(normalizedMemberName);
|
bootstrapContextMembers.size === 1 && bootstrapContextMembers.has(normalizedMemberName);
|
||||||
|
let outcome: BootstrapTranscriptOutcome | null = null;
|
||||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||||
const line = lines[index]?.trim();
|
const line = lines[index]?.trim();
|
||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
|
|
@ -30196,13 +30220,21 @@ export class TeamProvisioningService {
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
return { kind: 'failure', observedAt, reason };
|
outcome = { kind: 'failure', observedAt, reason };
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
const successSource = getBootstrapTranscriptSuccessSource(text, teamName, memberName);
|
const successSource = getBootstrapTranscriptSuccessSource(text, teamName, memberName);
|
||||||
if (successSource) {
|
if (successSource) {
|
||||||
return { kind: 'success', observedAt, source: successSource };
|
outcome = { kind: 'success', observedAt, source: successSource };
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.setBootstrapTranscriptOutcomeCacheEntry(cacheKey, {
|
||||||
|
mtimeMs: stat.mtimeMs,
|
||||||
|
size: stat.size,
|
||||||
|
outcome,
|
||||||
|
});
|
||||||
|
return outcome;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -30212,6 +30244,46 @@ export class TeamProvisioningService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildBootstrapTranscriptOutcomeCacheKey(input: {
|
||||||
|
filePath: string;
|
||||||
|
sinceMs: number | null;
|
||||||
|
memberName: string;
|
||||||
|
teamName: string;
|
||||||
|
allowAnonymousFailure: boolean;
|
||||||
|
contextMemberNames: readonly string[];
|
||||||
|
}): string {
|
||||||
|
const normalizedContextMembers = Array.from(
|
||||||
|
new Set(input.contextMemberNames.map((name) => name.trim().toLowerCase()).filter(Boolean))
|
||||||
|
)
|
||||||
|
.sort()
|
||||||
|
.join('\0');
|
||||||
|
return [
|
||||||
|
input.filePath,
|
||||||
|
input.sinceMs ?? '',
|
||||||
|
input.memberName,
|
||||||
|
input.teamName.trim().toLowerCase(),
|
||||||
|
input.allowAnonymousFailure ? '1' : '0',
|
||||||
|
normalizedContextMembers,
|
||||||
|
].join('\0');
|
||||||
|
}
|
||||||
|
|
||||||
|
private setBootstrapTranscriptOutcomeCacheEntry(
|
||||||
|
cacheKey: string,
|
||||||
|
entry: BootstrapTranscriptOutcomeCacheEntry
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
!this.bootstrapTranscriptOutcomeCache.has(cacheKey) &&
|
||||||
|
this.bootstrapTranscriptOutcomeCache.size >=
|
||||||
|
TeamProvisioningService.BOOTSTRAP_TRANSCRIPT_OUTCOME_CACHE_MAX_ENTRIES
|
||||||
|
) {
|
||||||
|
const oldestKey = this.bootstrapTranscriptOutcomeCache.keys().next().value;
|
||||||
|
if (oldestKey) {
|
||||||
|
this.bootstrapTranscriptOutcomeCache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.bootstrapTranscriptOutcomeCache.set(cacheKey, entry);
|
||||||
|
}
|
||||||
|
|
||||||
private async readBootstrapTranscriptOutcomesInProjectRoot(
|
private async readBootstrapTranscriptOutcomesInProjectRoot(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberName: string,
|
memberName: string,
|
||||||
|
|
|
||||||
|
|
@ -623,6 +623,13 @@ type TeamProvisioningServicePrivateHarness = {
|
||||||
readProcessUsageStatsByPid: (
|
readProcessUsageStatsByPid: (
|
||||||
pids: readonly number[]
|
pids: readonly number[]
|
||||||
) => Promise<Map<number, { rssBytes?: number; cpuPercent?: number }>>;
|
) => Promise<Map<number, { rssBytes?: number; cpuPercent?: number }>>;
|
||||||
|
readRecentBootstrapTranscriptOutcome: (
|
||||||
|
filePath: string,
|
||||||
|
sinceMs: number | null,
|
||||||
|
memberName: string,
|
||||||
|
teamName: string,
|
||||||
|
options?: { allowAnonymousFailure?: boolean; contextMemberNames?: readonly string[] }
|
||||||
|
) => Promise<{ kind: string; observedAt: string; source?: string; reason?: string } | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function privateHarness(svc: TeamProvisioningService): TeamProvisioningServicePrivateHarness {
|
function privateHarness(svc: TeamProvisioningService): TeamProvisioningServicePrivateHarness {
|
||||||
|
|
@ -21025,6 +21032,57 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(result.statuses.tom?.runtimeDiagnosticSeverity).toBeUndefined();
|
expect(result.statuses.tom?.runtimeDiagnosticSeverity).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refreshes cached bootstrap transcript outcome when the transcript file changes', async () => {
|
||||||
|
const teamName = 'zz-unit-bootstrap-transcript-cache-refresh';
|
||||||
|
const memberName = 'tom';
|
||||||
|
const transcriptPath = path.join(tempProjectsBase, 'bootstrap-cache.jsonl');
|
||||||
|
const writeTranscriptText = async (text: string, timestamp: string): Promise<void> => {
|
||||||
|
await fsPromises.writeFile(
|
||||||
|
transcriptPath,
|
||||||
|
`${JSON.stringify({
|
||||||
|
timestamp,
|
||||||
|
agentName: memberName,
|
||||||
|
text,
|
||||||
|
})}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const updatedAt = new Date(Date.now() + 5_000);
|
||||||
|
await fsPromises.utimes(transcriptPath, updatedAt, updatedAt);
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeTranscriptText(
|
||||||
|
`member briefing for ${memberName} on team "${teamName}" (${teamName}). Ready.`,
|
||||||
|
'2026-05-24T09:25:42.904Z'
|
||||||
|
);
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const firstOutcome = await privateHarness(svc).readRecentBootstrapTranscriptOutcome(
|
||||||
|
transcriptPath,
|
||||||
|
null,
|
||||||
|
memberName,
|
||||||
|
teamName
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(firstOutcome).toMatchObject({ kind: 'success', source: 'member_briefing' });
|
||||||
|
|
||||||
|
await writeTranscriptText(
|
||||||
|
'bootstrap failed: model not found during teammate startup',
|
||||||
|
'2026-05-24T09:26:42.904Z'
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondOutcome = await privateHarness(svc).readRecentBootstrapTranscriptOutcome(
|
||||||
|
transcriptPath,
|
||||||
|
null,
|
||||||
|
memberName,
|
||||||
|
teamName
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(secondOutcome).toMatchObject({
|
||||||
|
kind: 'failure',
|
||||||
|
reason: 'bootstrap failed: model not found during teammate startup',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('does not heal cleanup-finalized launch failures from stale bootstrap-state confirmation', async () => {
|
it('does not heal cleanup-finalized launch failures from stale bootstrap-state confirmation', async () => {
|
||||||
allowConsoleLogs();
|
allowConsoleLogs();
|
||||||
const teamName = 'zz-unit-cleanup-finalized-stale-bootstrap-ignored';
|
const teamName = 'zz-unit-cleanup-finalized-stale-bootstrap-ignored';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue