perf(team): cache transcript and telemetry scans
This commit is contained in:
parent
5bc9f6db7b
commit
ff0543caf9
10 changed files with 1084 additions and 93 deletions
|
|
@ -28,17 +28,21 @@ export interface RuntimeProcessTableRow {
|
||||||
pid: number;
|
pid: number;
|
||||||
ppid: number;
|
ppid: number;
|
||||||
command: string;
|
command: string;
|
||||||
|
cpuPercent?: number;
|
||||||
|
rssBytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow[] {
|
export function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow[] {
|
||||||
const rows: RuntimeProcessTableRow[] = [];
|
const rows: RuntimeProcessTableRow[] = [];
|
||||||
for (const line of output.split('\n')) {
|
for (const line of output.split('\n')) {
|
||||||
const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
|
const match = /^\s*(\d+)\s+(\d+)\s+(?:(\d+(?:\.\d+)?)\s+(\d+)\s+)?(.*)$/.exec(line);
|
||||||
if (!match) continue;
|
if (!match) continue;
|
||||||
|
|
||||||
const pid = Number.parseInt(match[1], 10);
|
const pid = Number.parseInt(match[1], 10);
|
||||||
const ppid = Number.parseInt(match[2], 10);
|
const ppid = Number.parseInt(match[2], 10);
|
||||||
const command = match[3]?.trim() ?? '';
|
const cpuPercent = match[3] != null ? Number.parseFloat(match[3]) : Number.NaN;
|
||||||
|
const rssKb = match[4] != null ? Number.parseInt(match[4], 10) : Number.NaN;
|
||||||
|
const command = match[5]?.trim() ?? '';
|
||||||
if (
|
if (
|
||||||
Number.isFinite(pid) &&
|
Number.isFinite(pid) &&
|
||||||
pid > 0 &&
|
pid > 0 &&
|
||||||
|
|
@ -46,7 +50,13 @@ export function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow
|
||||||
ppid >= 0 &&
|
ppid >= 0 &&
|
||||||
command.length > 0
|
command.length > 0
|
||||||
) {
|
) {
|
||||||
rows.push({ pid, ppid, command });
|
rows.push({
|
||||||
|
pid,
|
||||||
|
ppid,
|
||||||
|
command,
|
||||||
|
...(Number.isFinite(cpuPercent) && cpuPercent >= 0 ? { cpuPercent } : {}),
|
||||||
|
...(Number.isFinite(rssKb) && rssKb >= 0 ? { rssBytes: rssKb * 1024 } : {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
|
|
@ -169,7 +179,12 @@ export class TmuxPlatformCommandExecutor {
|
||||||
async listRuntimeProcesses(): Promise<RuntimeProcessTableRow[]> {
|
async listRuntimeProcesses(): Promise<RuntimeProcessTableRow[]> {
|
||||||
const result =
|
const result =
|
||||||
process.platform === 'win32'
|
process.platform === 'win32'
|
||||||
? await this.#wslService.execInPreferredDistro(['ps', '-ax', '-o', 'pid=,ppid=,command='])
|
? await this.#wslService.execInPreferredDistro([
|
||||||
|
'ps',
|
||||||
|
'-ax',
|
||||||
|
'-o',
|
||||||
|
'pid=,ppid=,pcpu=,rss=,command=',
|
||||||
|
])
|
||||||
: await this.#execNativePs();
|
: await this.#execNativePs();
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
throw new Error(result.stderr || 'Failed to list runtime processes');
|
throw new Error(result.stderr || 'Failed to list runtime processes');
|
||||||
|
|
@ -251,7 +266,7 @@ export class TmuxPlatformCommandExecutor {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
execFile(
|
execFile(
|
||||||
'ps',
|
'ps',
|
||||||
['-ax', '-o', 'pid=,ppid=,command='],
|
['-ax', '-o', 'pid=,ppid=,pcpu=,rss=,command='],
|
||||||
{ env: process.env, timeout: 3_000, maxBuffer: 2 * 1024 * 1024 },
|
{ env: process.env, timeout: 3_000, maxBuffer: 2 * 1024 * 1024 },
|
||||||
(error, stdout, stderr) => {
|
(error, stdout, stderr) => {
|
||||||
const errorCode =
|
const errorCode =
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ describe('TmuxPlatformCommandExecutor', () => {
|
||||||
setPlatform('win32');
|
setPlatform('win32');
|
||||||
const execInPreferredDistro = vi.fn(async () => ({
|
const execInPreferredDistro = vi.fn(async () => ({
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
stdout: ' 42 1 opencode runtime --team-name demo\n',
|
stdout: ' 42 1 3.5 1024 opencode runtime --team-name demo\n',
|
||||||
stderr: '',
|
stderr: '',
|
||||||
}));
|
}));
|
||||||
const executor = new TmuxPlatformCommandExecutor(
|
const executor = new TmuxPlatformCommandExecutor(
|
||||||
|
|
@ -116,9 +116,20 @@ describe('TmuxPlatformCommandExecutor', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(executor.listRuntimeProcesses()).resolves.toEqual([
|
await expect(executor.listRuntimeProcesses()).resolves.toEqual([
|
||||||
{ pid: 42, ppid: 1, command: 'opencode runtime --team-name demo' },
|
{
|
||||||
|
pid: 42,
|
||||||
|
ppid: 1,
|
||||||
|
command: 'opencode runtime --team-name demo',
|
||||||
|
cpuPercent: 3.5,
|
||||||
|
rssBytes: 1024 * 1024,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(execInPreferredDistro).toHaveBeenCalledWith([
|
||||||
|
'ps',
|
||||||
|
'-ax',
|
||||||
|
'-o',
|
||||||
|
'pid=,ppid=,pcpu=,rss=,command=',
|
||||||
]);
|
]);
|
||||||
expect(execInPreferredDistro).toHaveBeenCalledWith(['ps', '-ax', '-o', 'pid=,ppid=,command=']);
|
|
||||||
expect(childProcess.execFile).not.toHaveBeenCalled();
|
expect(childProcess.execFile).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -687,6 +687,26 @@ type BootstrapTranscriptOutcome =
|
||||||
reason: string;
|
reason: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface BootstrapTranscriptIndexedLine {
|
||||||
|
startOffset: number;
|
||||||
|
endOffset: number;
|
||||||
|
timestampMs: number;
|
||||||
|
timestampText?: string;
|
||||||
|
agentName?: string | null;
|
||||||
|
text?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BootstrapTranscriptFileIndex {
|
||||||
|
filePath: string;
|
||||||
|
size: number;
|
||||||
|
mtimeMs: number;
|
||||||
|
dev: number;
|
||||||
|
ino: number;
|
||||||
|
partialText: string;
|
||||||
|
partialStartOffset: number;
|
||||||
|
lines: BootstrapTranscriptIndexedLine[];
|
||||||
|
}
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ActiveToolCall,
|
ActiveToolCall,
|
||||||
AgentActionMode,
|
AgentActionMode,
|
||||||
|
|
@ -754,6 +774,7 @@ import type {
|
||||||
// its initial two-sample pass. Keep this above slow PowerShell startup time, or
|
// its initial two-sample pass. Keep this above slow PowerShell startup time, or
|
||||||
// the first sample can expire before the recursive second read and loop again.
|
// the first sample can expire before the recursive second read and loop again.
|
||||||
const RUNTIME_PIDUSAGE_OPTIONS = process.platform === 'win32' ? { maxage: 10_000 } : { maxage: 0 };
|
const RUNTIME_PIDUSAGE_OPTIONS = process.platform === 'win32' ? { maxage: 10_000 } : { maxage: 0 };
|
||||||
|
const READ_PROCESS_COMMAND_TIMEOUT_MS = 1_000;
|
||||||
|
|
||||||
interface RuntimeProcessUsageStats {
|
interface RuntimeProcessUsageStats {
|
||||||
rssBytes?: number;
|
rssBytes?: number;
|
||||||
|
|
@ -3292,6 +3313,8 @@ export class TeamProvisioningService {
|
||||||
private static readonly AGENT_RUNTIME_RESOURCE_HISTORY_LIMIT = 60;
|
private static readonly AGENT_RUNTIME_RESOURCE_HISTORY_LIMIT = 60;
|
||||||
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_CACHE_TTL_MS = 2_000;
|
||||||
|
private static readonly RUNTIME_PROCESS_USAGE_CACHE_TTL_MS = 2_000;
|
||||||
private static readonly RUNTIME_PROCESS_TABLE_TIMEOUT_MS = 1_500;
|
private static readonly RUNTIME_PROCESS_TABLE_TIMEOUT_MS = 1_500;
|
||||||
private static readonly RUNTIME_WINDOWS_PROCESS_TABLE_TIMEOUT_MS = 1_500;
|
private static readonly RUNTIME_WINDOWS_PROCESS_TABLE_TIMEOUT_MS = 1_500;
|
||||||
private static readonly RUNTIME_PIDUSAGE_BATCH_TIMEOUT_MS = 2_000;
|
private static readonly RUNTIME_PIDUSAGE_BATCH_TIMEOUT_MS = 2_000;
|
||||||
|
|
@ -3302,6 +3325,8 @@ export class TeamProvisioningService {
|
||||||
private static readonly RETAINED_PROVISIONING_PROGRESS_TTL_MS = 5 * 60_000;
|
private static readonly RETAINED_PROVISIONING_PROGRESS_TTL_MS = 5 * 60_000;
|
||||||
private static readonly OPENCODE_RUNTIME_DELIVERY_ADVISORY_EVENT_TTL_MS = 24 * 60 * 60_000;
|
private static readonly OPENCODE_RUNTIME_DELIVERY_ADVISORY_EVENT_TTL_MS = 24 * 60 * 60_000;
|
||||||
private static readonly OPENCODE_RUNTIME_DELIVERY_LEAD_NOTICE_TTL_MS = 24 * 60 * 60_000;
|
private static readonly OPENCODE_RUNTIME_DELIVERY_LEAD_NOTICE_TTL_MS = 24 * 60 * 60_000;
|
||||||
|
private static readonly BOOTSTRAP_TRANSCRIPT_OUTCOME_CACHE_MAX = 20_000;
|
||||||
|
private static readonly BOOTSTRAP_TRANSCRIPT_FILE_INDEX_MAX = 512;
|
||||||
|
|
||||||
private readonly runs = new Map<string, ProvisioningRun>();
|
private readonly runs = new Map<string, ProvisioningRun>();
|
||||||
private readonly provisioningRunByTeam = new Map<string, string>();
|
private readonly provisioningRunByTeam = new Map<string, string>();
|
||||||
|
|
@ -3396,6 +3421,38 @@ export class TeamProvisioningService {
|
||||||
includesWindowsHostRows: boolean;
|
includesWindowsHostRows: boolean;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
private runtimeProcessTableCache:
|
||||||
|
| {
|
||||||
|
expiresAtMs: number;
|
||||||
|
rows: RuntimeTelemetryProcessTableRow[] | null;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
private runtimeProcessTableInFlight:
|
||||||
|
| Promise<RuntimeTelemetryProcessTableRow[] | null>
|
||||||
|
| undefined;
|
||||||
|
private readonly runtimeProcessUsageStatsCacheByPid = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
expiresAtMs: number;
|
||||||
|
stats: RuntimeProcessUsageStats | null;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
private readonly bootstrapTranscriptOutcomeCache = new Map<
|
||||||
|
string,
|
||||||
|
BootstrapTranscriptOutcome | null
|
||||||
|
>();
|
||||||
|
private readonly bootstrapTranscriptOutcomeInFlight = new Map<
|
||||||
|
string,
|
||||||
|
Promise<BootstrapTranscriptOutcome | null>
|
||||||
|
>();
|
||||||
|
private readonly bootstrapTranscriptFileIndexByPath = new Map<
|
||||||
|
string,
|
||||||
|
BootstrapTranscriptFileIndex
|
||||||
|
>();
|
||||||
|
private readonly bootstrapTranscriptFileIndexInFlight = new Map<
|
||||||
|
string,
|
||||||
|
Promise<BootstrapTranscriptFileIndex | null>
|
||||||
|
>();
|
||||||
private readonly agentRuntimeSnapshotInFlightByTeam = new Map<
|
private readonly agentRuntimeSnapshotInFlightByTeam = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
|
|
@ -5126,6 +5183,7 @@ export class TeamProvisioningService {
|
||||||
return execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
return execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
timeout: READ_PROCESS_COMMAND_TIMEOUT_MS,
|
||||||
}).trim();
|
}).trim();
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -14084,8 +14142,9 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
let runtimeUsageTreesByRootPid = new Map<number, { pids: number[]; truncated: boolean }>();
|
let runtimeUsageTreesByRootPid = new Map<number, { pids: number[]; truncated: boolean }>();
|
||||||
let usageStatsByPid = new Map<number, RuntimeProcessUsageStats>();
|
let usageStatsByPid = new Map<number, RuntimeProcessUsageStats>();
|
||||||
|
let runtimeProcessRowsForSnapshot: RuntimeTelemetryProcessTableRow[] | null = null;
|
||||||
try {
|
try {
|
||||||
const runtimeProcessRows =
|
runtimeProcessRowsForSnapshot =
|
||||||
runtimeRootOwnersByPid.size > 0
|
runtimeRootOwnersByPid.size > 0
|
||||||
? await this.readRuntimeProcessRowsForUsageSnapshot(teamName, {
|
? await this.readRuntimeProcessRowsForUsageSnapshot(teamName, {
|
||||||
includeWindowsHostRows: process.platform === 'win32',
|
includeWindowsHostRows: process.platform === 'win32',
|
||||||
|
|
@ -14093,18 +14152,31 @@ export class TeamProvisioningService {
|
||||||
: null;
|
: null;
|
||||||
this.addRuntimeRootOwnersFromProcessRows(
|
this.addRuntimeRootOwnersFromProcessRows(
|
||||||
teamName,
|
teamName,
|
||||||
runtimeProcessRows,
|
runtimeProcessRowsForSnapshot,
|
||||||
runtimeRootOwnersByPid
|
runtimeRootOwnersByPid
|
||||||
);
|
);
|
||||||
runtimeUsageTreesByRootPid = this.buildRuntimeUsageProcessTrees(
|
runtimeUsageTreesByRootPid = this.buildRuntimeUsageProcessTrees(
|
||||||
[...runtimeUsageRootPids],
|
[...runtimeUsageRootPids],
|
||||||
runtimeProcessRows,
|
runtimeProcessRowsForSnapshot,
|
||||||
runtimeRootOwnersByPid
|
runtimeRootOwnersByPid
|
||||||
);
|
);
|
||||||
const runtimeUsagePids = [
|
const runtimeUsagePids = [
|
||||||
...new Set([...runtimeUsageTreesByRootPid.values()].flatMap((tree) => tree.pids)),
|
...new Set([...runtimeUsageTreesByRootPid.values()].flatMap((tree) => tree.pids)),
|
||||||
];
|
];
|
||||||
usageStatsByPid = await this.readProcessUsageStatsByPid(runtimeUsagePids);
|
usageStatsByPid = this.buildProcessUsageStatsFromRows(
|
||||||
|
runtimeProcessRowsForSnapshot,
|
||||||
|
runtimeUsagePids
|
||||||
|
);
|
||||||
|
const pidsMissingUsageStats =
|
||||||
|
runtimeProcessRowsForSnapshot == null
|
||||||
|
? runtimeUsagePids.filter((pid) => !usageStatsByPid.has(pid))
|
||||||
|
: [];
|
||||||
|
if (pidsMissingUsageStats.length > 0) {
|
||||||
|
const sampledUsageStats = await this.readProcessUsageStatsByPid(pidsMissingUsageStats);
|
||||||
|
for (const [pid, stats] of sampledUsageStats) {
|
||||||
|
usageStatsByPid.set(pid, stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[${teamName}] Runtime telemetry sampling failed; continuing without resource metrics: ${
|
`[${teamName}] Runtime telemetry sampling failed; continuing without resource metrics: ${
|
||||||
|
|
@ -14409,6 +14481,7 @@ export class TeamProvisioningService {
|
||||||
rssPid &&
|
rssPid &&
|
||||||
!usageStatsByPid.has(rssPid) &&
|
!usageStatsByPid.has(rssPid) &&
|
||||||
isSharedOpenCodeHost &&
|
isSharedOpenCodeHost &&
|
||||||
|
runtimeProcessRowsForSnapshot == null &&
|
||||||
typeof rssPid === 'number' &&
|
typeof rssPid === 'number' &&
|
||||||
rssPid > 0
|
rssPid > 0
|
||||||
) {
|
) {
|
||||||
|
|
@ -25278,26 +25351,11 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let processRows: RuntimeTelemetryProcessTableRow[] = [];
|
const currentProcessRows = await this.readCurrentRuntimeProcessTableRows(
|
||||||
let processTableAvailable = true;
|
'process table runtime snapshot'
|
||||||
try {
|
);
|
||||||
processRows =
|
const processRows = currentProcessRows ?? [];
|
||||||
this.normalizeRuntimeProcessRowsForTelemetry(
|
const processTableAvailable = currentProcessRows !== null;
|
||||||
await this.withRuntimeTelemetryTimeout(
|
|
||||||
listRuntimeProcessTableForCurrentPlatform(),
|
|
||||||
TeamProvisioningService.RUNTIME_PROCESS_TABLE_TIMEOUT_MS,
|
|
||||||
'process table runtime snapshot'
|
|
||||||
),
|
|
||||||
process.platform === 'win32' ? 'wsl' : 'native'
|
|
||||||
) ?? [];
|
|
||||||
} catch (error) {
|
|
||||||
processTableAvailable = false;
|
|
||||||
logger.debug(
|
|
||||||
`[${teamName}] Failed to read process table for runtime snapshot: ${
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.runtimeProcessRowsForUsageSnapshotByTeam.set(teamName, {
|
this.runtimeProcessRowsForUsageSnapshotByTeam.set(teamName, {
|
||||||
expiresAtMs: Date.now() + TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS,
|
expiresAtMs: Date.now() + TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS,
|
||||||
generation: generationAtStart,
|
generation: generationAtStart,
|
||||||
|
|
@ -25667,6 +25725,8 @@ export class TeamProvisioningService {
|
||||||
const pid = normalizeRuntimeTelemetryNumber(candidate.pid);
|
const pid = normalizeRuntimeTelemetryNumber(candidate.pid);
|
||||||
const ppid = normalizeRuntimeTelemetryNumber(candidate.ppid);
|
const ppid = normalizeRuntimeTelemetryNumber(candidate.ppid);
|
||||||
const command = typeof candidate.command === 'string' ? candidate.command.trim() : '';
|
const command = typeof candidate.command === 'string' ? candidate.command.trim() : '';
|
||||||
|
const cpuPercent = normalizeRuntimeTelemetryNumber(candidate.cpuPercent);
|
||||||
|
const rssBytes = normalizeRuntimeTelemetryNumber(candidate.rssBytes);
|
||||||
if (pid != null && pid > 0 && ppid != null && ppid >= 0 && command.length > 0) {
|
if (pid != null && pid > 0 && ppid != null && ppid >= 0 && command.length > 0) {
|
||||||
const runtimeTelemetrySource =
|
const runtimeTelemetrySource =
|
||||||
source ??
|
source ??
|
||||||
|
|
@ -25679,6 +25739,8 @@ export class TeamProvisioningService {
|
||||||
pid: Math.floor(pid),
|
pid: Math.floor(pid),
|
||||||
ppid: Math.floor(ppid),
|
ppid: Math.floor(ppid),
|
||||||
command,
|
command,
|
||||||
|
...(cpuPercent != null && cpuPercent >= 0 ? { cpuPercent } : {}),
|
||||||
|
...(rssBytes != null && rssBytes >= 0 ? { rssBytes } : {}),
|
||||||
...(runtimeTelemetrySource ? { runtimeTelemetrySource } : {}),
|
...(runtimeTelemetrySource ? { runtimeTelemetrySource } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -25686,6 +25748,69 @@ export class TeamProvisioningService {
|
||||||
return normalizedRows;
|
return normalizedRows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async readCurrentRuntimeProcessTableRows(
|
||||||
|
label: string
|
||||||
|
): Promise<RuntimeTelemetryProcessTableRow[] | null> {
|
||||||
|
const cached = this.runtimeProcessTableCache;
|
||||||
|
if (cached && cached.expiresAtMs > Date.now()) {
|
||||||
|
return cached.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.runtimeProcessTableInFlight) {
|
||||||
|
return this.runtimeProcessTableInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = this.withRuntimeTelemetryTimeout(
|
||||||
|
listRuntimeProcessTableForCurrentPlatform(),
|
||||||
|
TeamProvisioningService.RUNTIME_PROCESS_TABLE_TIMEOUT_MS,
|
||||||
|
label
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
(rows) =>
|
||||||
|
this.normalizeRuntimeProcessRowsForTelemetry(
|
||||||
|
rows,
|
||||||
|
process.platform === 'win32' ? 'wsl' : 'native'
|
||||||
|
) ?? []
|
||||||
|
)
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
logger.debug(
|
||||||
|
`Failed to read process table for ${label}: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.then((rows) => {
|
||||||
|
this.runtimeProcessTableCache = {
|
||||||
|
expiresAtMs: Date.now() + TeamProvisioningService.RUNTIME_PROCESS_TABLE_CACHE_TTL_MS,
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
return rows;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (this.runtimeProcessTableInFlight === request) {
|
||||||
|
this.runtimeProcessTableInFlight = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.runtimeProcessTableInFlight = request;
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findRuntimeProcessCommandByPid(
|
||||||
|
processRows: readonly RuntimeTelemetryProcessTableRow[] | null,
|
||||||
|
pid: number
|
||||||
|
): string | null {
|
||||||
|
if (!Array.isArray(processRows) || !Number.isFinite(pid) || pid <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (const row of processRows) {
|
||||||
|
if (row.pid === pid) {
|
||||||
|
return row.command.trim() || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private async readRuntimeProcessRowsForUsageSnapshot(
|
private async readRuntimeProcessRowsForUsageSnapshot(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
options: { includeWindowsHostRows?: boolean } = {}
|
options: { includeWindowsHostRows?: boolean } = {}
|
||||||
|
|
@ -25709,16 +25834,9 @@ export class TeamProvisioningService {
|
||||||
let runtimeProcessTableAvailable = rows != null;
|
let runtimeProcessTableAvailable = rows != null;
|
||||||
try {
|
try {
|
||||||
if (!rows) {
|
if (!rows) {
|
||||||
rows =
|
rows = await this.readCurrentRuntimeProcessTableRows('process table runtime telemetry');
|
||||||
this.normalizeRuntimeProcessRowsForTelemetry(
|
runtimeProcessTableAvailable = rows != null;
|
||||||
await this.withRuntimeTelemetryTimeout(
|
rows = rows ?? [];
|
||||||
listRuntimeProcessTableForCurrentPlatform(),
|
|
||||||
TeamProvisioningService.RUNTIME_PROCESS_TABLE_TIMEOUT_MS,
|
|
||||||
'process table runtime telemetry'
|
|
||||||
),
|
|
||||||
process.platform === 'win32' ? 'wsl' : 'native'
|
|
||||||
) ?? [];
|
|
||||||
runtimeProcessTableAvailable = true;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|
@ -26046,6 +26164,28 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildProcessUsageStatsFromRows(
|
||||||
|
processRows: readonly RuntimeTelemetryProcessTableRow[] | null,
|
||||||
|
pids: readonly number[]
|
||||||
|
): Map<number, RuntimeProcessUsageStats> {
|
||||||
|
const usageStatsByPid = new Map<number, RuntimeProcessUsageStats>();
|
||||||
|
const requestedPids = new Set(pids.filter((pid) => Number.isFinite(pid) && pid > 0));
|
||||||
|
if (!Array.isArray(processRows) || requestedPids.size === 0) {
|
||||||
|
return usageStatsByPid;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of processRows) {
|
||||||
|
if (!requestedPids.has(row.pid)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const usageStats = this.normalizeRuntimeProcessUsageStats(row);
|
||||||
|
if (usageStats) {
|
||||||
|
usageStatsByPid.set(row.pid, usageStats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return usageStatsByPid;
|
||||||
|
}
|
||||||
|
|
||||||
private async readProcessUsageStatsByPid(
|
private async readProcessUsageStatsByPid(
|
||||||
pids: readonly number[]
|
pids: readonly number[]
|
||||||
): Promise<Map<number, RuntimeProcessUsageStats>> {
|
): Promise<Map<number, RuntimeProcessUsageStats>> {
|
||||||
|
|
@ -26056,20 +26196,57 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const usageStatsByPid = new Map<number, RuntimeProcessUsageStats>();
|
const usageStatsByPid = new Map<number, RuntimeProcessUsageStats>();
|
||||||
|
const pidsToRead: number[] = [];
|
||||||
|
const now = Date.now();
|
||||||
|
for (const pid of uniquePids) {
|
||||||
|
const cached = this.runtimeProcessUsageStatsCacheByPid.get(pid);
|
||||||
|
if (cached && cached.expiresAtMs > now) {
|
||||||
|
if (cached.stats) {
|
||||||
|
usageStatsByPid.set(pid, { ...cached.stats });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pidsToRead.push(pid);
|
||||||
|
}
|
||||||
|
if (pidsToRead.length === 0) {
|
||||||
|
return usageStatsByPid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rememberUsageStats = (
|
||||||
|
pid: number,
|
||||||
|
stats: RuntimeProcessUsageStats | null | undefined
|
||||||
|
): void => {
|
||||||
|
const normalized = stats ? { ...stats } : null;
|
||||||
|
this.runtimeProcessUsageStatsCacheByPid.set(pid, {
|
||||||
|
expiresAtMs: Date.now() + TeamProvisioningService.RUNTIME_PROCESS_USAGE_CACHE_TTL_MS,
|
||||||
|
stats: normalized,
|
||||||
|
});
|
||||||
|
if (normalized) {
|
||||||
|
usageStatsByPid.set(pid, { ...normalized });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const options = RUNTIME_PIDUSAGE_OPTIONS;
|
const options = RUNTIME_PIDUSAGE_OPTIONS;
|
||||||
try {
|
try {
|
||||||
const statsByPid = await this.withRuntimeTelemetryTimeout(
|
const statsByPid = await this.withRuntimeTelemetryTimeout(
|
||||||
pidusage(uniquePids, options),
|
pidusage(pidsToRead, options),
|
||||||
TeamProvisioningService.RUNTIME_PIDUSAGE_BATCH_TIMEOUT_MS,
|
TeamProvisioningService.RUNTIME_PIDUSAGE_BATCH_TIMEOUT_MS,
|
||||||
'pidusage batch runtime telemetry'
|
'pidusage batch runtime telemetry'
|
||||||
);
|
);
|
||||||
|
const observedPids = new Set<number>();
|
||||||
for (const [rawPid, stat] of Object.entries(
|
for (const [rawPid, stat] of Object.entries(
|
||||||
statsByPid && typeof statsByPid === 'object' ? statsByPid : {}
|
statsByPid && typeof statsByPid === 'object' ? statsByPid : {}
|
||||||
)) {
|
)) {
|
||||||
const pid = Number.parseInt(rawPid, 10);
|
const pid = Number.parseInt(rawPid, 10);
|
||||||
const usageStats = this.normalizeRuntimeProcessUsageStats(stat);
|
const usageStats = this.normalizeRuntimeProcessUsageStats(stat);
|
||||||
if (Number.isFinite(pid) && pid > 0 && usageStats) {
|
if (Number.isFinite(pid) && pid > 0) {
|
||||||
usageStatsByPid.set(pid, usageStats);
|
observedPids.add(pid);
|
||||||
|
rememberUsageStats(pid, usageStats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const pid of pidsToRead) {
|
||||||
|
if (!observedPids.has(pid)) {
|
||||||
|
rememberUsageStats(pid, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return usageStatsByPid;
|
return usageStatsByPid;
|
||||||
|
|
@ -26087,10 +26264,10 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
for (
|
for (
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
offset < uniquePids.length;
|
offset < pidsToRead.length;
|
||||||
offset += TeamProvisioningService.RUNTIME_PIDUSAGE_FALLBACK_CONCURRENCY
|
offset += TeamProvisioningService.RUNTIME_PIDUSAGE_FALLBACK_CONCURRENCY
|
||||||
) {
|
) {
|
||||||
const chunk = uniquePids.slice(
|
const chunk = pidsToRead.slice(
|
||||||
offset,
|
offset,
|
||||||
offset + TeamProvisioningService.RUNTIME_PIDUSAGE_FALLBACK_CONCURRENCY
|
offset + TeamProvisioningService.RUNTIME_PIDUSAGE_FALLBACK_CONCURRENCY
|
||||||
);
|
);
|
||||||
|
|
@ -26103,13 +26280,12 @@ export class TeamProvisioningService {
|
||||||
`pidusage runtime telemetry pid=${pid}`
|
`pidusage runtime telemetry pid=${pid}`
|
||||||
);
|
);
|
||||||
const usageStats = this.normalizeRuntimeProcessUsageStats(stat);
|
const usageStats = this.normalizeRuntimeProcessUsageStats(stat);
|
||||||
if (usageStats) {
|
rememberUsageStats(pid, usageStats);
|
||||||
usageStatsByPid.set(pid, usageStats);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof RuntimeTelemetryTimeoutError) {
|
if (error instanceof RuntimeTelemetryTimeoutError) {
|
||||||
logger.debug(error.message);
|
logger.debug(error.message);
|
||||||
}
|
}
|
||||||
|
rememberUsageStats(pid, null);
|
||||||
// Process likely exited between discovery and sampling.
|
// Process likely exited between discovery and sampling.
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -30026,7 +30202,6 @@ export class TeamProvisioningService {
|
||||||
contextMemberNames?: readonly string[];
|
contextMemberNames?: readonly string[];
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<BootstrapTranscriptOutcome | null> {
|
): Promise<BootstrapTranscriptOutcome | null> {
|
||||||
let handle: fs.promises.FileHandle | null = null;
|
|
||||||
const normalizedMemberName = memberName.trim().toLowerCase();
|
const normalizedMemberName = memberName.trim().toLowerCase();
|
||||||
const contextMemberNames = Array.from(
|
const contextMemberNames = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
|
|
@ -30035,14 +30210,472 @@ export class TeamProvisioningService {
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
let stat: Awaited<ReturnType<typeof fs.promises.stat>>;
|
||||||
try {
|
try {
|
||||||
handle = await fs.promises.open(filePath, 'r');
|
stat = await fs.promises.stat(filePath);
|
||||||
const stat = await handle.stat();
|
|
||||||
if (!stat.isFile() || stat.size <= 0) {
|
if (!stat.isFile() || stat.size <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const start = Math.max(0, stat.size - TeamProvisioningService.BOOTSTRAP_FAILURE_TAIL_BYTES);
|
} catch {
|
||||||
const buffer = Buffer.alloc(stat.size - start);
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = [
|
||||||
|
filePath,
|
||||||
|
stat.mtimeMs,
|
||||||
|
stat.size,
|
||||||
|
sinceMs ?? '',
|
||||||
|
memberName.trim().toLowerCase(),
|
||||||
|
teamName.trim().toLowerCase(),
|
||||||
|
options.allowAnonymousFailure === true ? 'anonymous' : 'named',
|
||||||
|
contextMemberNames
|
||||||
|
.map((name) => name.toLowerCase())
|
||||||
|
.sort()
|
||||||
|
.join('\0'),
|
||||||
|
].join('\0');
|
||||||
|
if (this.bootstrapTranscriptOutcomeCache.has(cacheKey)) {
|
||||||
|
const cached = this.bootstrapTranscriptOutcomeCache.get(cacheKey);
|
||||||
|
return cached ? { ...cached } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = this.bootstrapTranscriptOutcomeInFlight.get(cacheKey);
|
||||||
|
if (existing) {
|
||||||
|
const outcome = await existing;
|
||||||
|
return outcome ? { ...outcome } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = this.scanRecentBootstrapTranscriptOutcome({
|
||||||
|
filePath,
|
||||||
|
statSize: stat.size,
|
||||||
|
statMtimeMs: stat.mtimeMs,
|
||||||
|
statDev: Number(stat.dev),
|
||||||
|
statIno: Number(stat.ino),
|
||||||
|
sinceMs,
|
||||||
|
normalizedMemberName,
|
||||||
|
contextMemberNames,
|
||||||
|
memberName,
|
||||||
|
teamName,
|
||||||
|
normalizedTeamName: teamName.trim().toLowerCase(),
|
||||||
|
allowAnonymousFailure: options.allowAnonymousFailure === true,
|
||||||
|
})
|
||||||
|
.then((outcome) => {
|
||||||
|
this.rememberBootstrapTranscriptOutcome(cacheKey, outcome);
|
||||||
|
return outcome;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (this.bootstrapTranscriptOutcomeInFlight.get(cacheKey) === request) {
|
||||||
|
this.bootstrapTranscriptOutcomeInFlight.delete(cacheKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.bootstrapTranscriptOutcomeInFlight.set(cacheKey, request);
|
||||||
|
const outcome = await request;
|
||||||
|
return outcome ? { ...outcome } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rememberBootstrapTranscriptOutcome(
|
||||||
|
cacheKey: string,
|
||||||
|
outcome: BootstrapTranscriptOutcome | null
|
||||||
|
): void {
|
||||||
|
this.bootstrapTranscriptOutcomeCache.set(cacheKey, outcome ? { ...outcome } : null);
|
||||||
|
if (
|
||||||
|
this.bootstrapTranscriptOutcomeCache.size <=
|
||||||
|
TeamProvisioningService.BOOTSTRAP_TRANSCRIPT_OUTCOME_CACHE_MAX
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const oldestKey = this.bootstrapTranscriptOutcomeCache.keys().next().value;
|
||||||
|
if (oldestKey) {
|
||||||
|
this.bootstrapTranscriptOutcomeCache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scanRecentBootstrapTranscriptOutcome(input: {
|
||||||
|
filePath: string;
|
||||||
|
statSize: number;
|
||||||
|
statMtimeMs: number;
|
||||||
|
statDev: number;
|
||||||
|
statIno: number;
|
||||||
|
sinceMs: number | null;
|
||||||
|
normalizedMemberName: string;
|
||||||
|
contextMemberNames: readonly string[];
|
||||||
|
memberName: string;
|
||||||
|
teamName: string;
|
||||||
|
normalizedTeamName: string;
|
||||||
|
allowAnonymousFailure: boolean;
|
||||||
|
}): Promise<BootstrapTranscriptOutcome | null> {
|
||||||
|
const indexedLines = await this.readBootstrapTranscriptIndexedLines(input).catch(() => null);
|
||||||
|
if (indexedLines) {
|
||||||
|
return this.selectBootstrapTranscriptOutcomeFromIndexedLines(indexedLines, input);
|
||||||
|
}
|
||||||
|
return this.scanRecentBootstrapTranscriptOutcomeFromFile(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readBootstrapTranscriptIndexedLines(input: {
|
||||||
|
filePath: string;
|
||||||
|
statSize: number;
|
||||||
|
statMtimeMs: number;
|
||||||
|
statDev: number;
|
||||||
|
statIno: number;
|
||||||
|
}): Promise<BootstrapTranscriptIndexedLine[] | null> {
|
||||||
|
const cached = this.bootstrapTranscriptFileIndexByPath.get(input.filePath);
|
||||||
|
if (
|
||||||
|
cached &&
|
||||||
|
cached.size === input.statSize &&
|
||||||
|
cached.mtimeMs === input.statMtimeMs &&
|
||||||
|
cached.dev === input.statDev &&
|
||||||
|
cached.ino === input.statIno
|
||||||
|
) {
|
||||||
|
return cached.lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inFlightKey = `${input.filePath}\0${input.statSize}\0${input.statMtimeMs}`;
|
||||||
|
const existing = this.bootstrapTranscriptFileIndexInFlight.get(inFlightKey);
|
||||||
|
if (existing) {
|
||||||
|
const index = await existing;
|
||||||
|
return index?.lines ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = this.readBootstrapTranscriptFileIndex(input)
|
||||||
|
.then((index) => {
|
||||||
|
if (index) {
|
||||||
|
this.rememberBootstrapTranscriptFileIndex(index);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (this.bootstrapTranscriptFileIndexInFlight.get(inFlightKey) === request) {
|
||||||
|
this.bootstrapTranscriptFileIndexInFlight.delete(inFlightKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.bootstrapTranscriptFileIndexInFlight.set(inFlightKey, request);
|
||||||
|
const index = await request;
|
||||||
|
return index?.lines ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rememberBootstrapTranscriptFileIndex(index: BootstrapTranscriptFileIndex): void {
|
||||||
|
this.bootstrapTranscriptFileIndexByPath.set(index.filePath, index);
|
||||||
|
if (
|
||||||
|
this.bootstrapTranscriptFileIndexByPath.size <=
|
||||||
|
TeamProvisioningService.BOOTSTRAP_TRANSCRIPT_FILE_INDEX_MAX
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const oldestKey = this.bootstrapTranscriptFileIndexByPath.keys().next().value;
|
||||||
|
if (oldestKey) {
|
||||||
|
this.bootstrapTranscriptFileIndexByPath.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readBootstrapTranscriptFileIndex(input: {
|
||||||
|
filePath: string;
|
||||||
|
statSize: number;
|
||||||
|
statMtimeMs: number;
|
||||||
|
statDev: number;
|
||||||
|
statIno: number;
|
||||||
|
}): Promise<BootstrapTranscriptFileIndex | null> {
|
||||||
|
const cached = this.bootstrapTranscriptFileIndexByPath.get(input.filePath);
|
||||||
|
if (
|
||||||
|
cached &&
|
||||||
|
input.statSize > cached.size &&
|
||||||
|
cached.dev === input.statDev &&
|
||||||
|
cached.ino === input.statIno &&
|
||||||
|
input.statMtimeMs >= cached.mtimeMs
|
||||||
|
) {
|
||||||
|
const appended = await this.appendBootstrapTranscriptFileIndex(cached, input).catch(
|
||||||
|
() => null
|
||||||
|
);
|
||||||
|
if (appended) {
|
||||||
|
return appended;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.rebuildBootstrapTranscriptFileIndex(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async rebuildBootstrapTranscriptFileIndex(input: {
|
||||||
|
filePath: string;
|
||||||
|
statSize: number;
|
||||||
|
statMtimeMs: number;
|
||||||
|
statDev: number;
|
||||||
|
statIno: number;
|
||||||
|
}): Promise<BootstrapTranscriptFileIndex | null> {
|
||||||
|
let handle: fs.promises.FileHandle | null = null;
|
||||||
|
try {
|
||||||
|
handle = await fs.promises.open(input.filePath, 'r');
|
||||||
|
const start = Math.max(
|
||||||
|
0,
|
||||||
|
input.statSize - TeamProvisioningService.BOOTSTRAP_FAILURE_TAIL_BYTES
|
||||||
|
);
|
||||||
|
const buffer = Buffer.alloc(input.statSize - start);
|
||||||
|
if (buffer.length === 0) {
|
||||||
|
return {
|
||||||
|
filePath: input.filePath,
|
||||||
|
size: input.statSize,
|
||||||
|
mtimeMs: input.statMtimeMs,
|
||||||
|
dev: input.statDev,
|
||||||
|
ino: input.statIno,
|
||||||
|
partialText: '',
|
||||||
|
partialStartOffset: input.statSize,
|
||||||
|
lines: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await handle.read(buffer, 0, buffer.length, start);
|
||||||
|
let text = buffer.toString('utf8');
|
||||||
|
let offsetBase = start;
|
||||||
|
if (start > 0) {
|
||||||
|
const firstNewlineIndex = text.indexOf('\n');
|
||||||
|
if (firstNewlineIndex < 0) {
|
||||||
|
return {
|
||||||
|
filePath: input.filePath,
|
||||||
|
size: input.statSize,
|
||||||
|
mtimeMs: input.statMtimeMs,
|
||||||
|
dev: input.statDev,
|
||||||
|
ino: input.statIno,
|
||||||
|
partialText: '',
|
||||||
|
partialStartOffset: input.statSize,
|
||||||
|
lines: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const droppedText = text.slice(0, firstNewlineIndex + 1);
|
||||||
|
offsetBase += Buffer.byteLength(droppedText, 'utf8');
|
||||||
|
text = text.slice(firstNewlineIndex + 1);
|
||||||
|
}
|
||||||
|
const parsed = this.parseBootstrapTranscriptIndexChunk(text, offsetBase);
|
||||||
|
const index: BootstrapTranscriptFileIndex = {
|
||||||
|
filePath: input.filePath,
|
||||||
|
size: input.statSize,
|
||||||
|
mtimeMs: input.statMtimeMs,
|
||||||
|
dev: input.statDev,
|
||||||
|
ino: input.statIno,
|
||||||
|
partialText: parsed.partialText,
|
||||||
|
partialStartOffset: parsed.partialStartOffset,
|
||||||
|
lines: parsed.lines,
|
||||||
|
};
|
||||||
|
this.pruneBootstrapTranscriptFileIndex(index);
|
||||||
|
return index;
|
||||||
|
} finally {
|
||||||
|
await handle?.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async appendBootstrapTranscriptFileIndex(
|
||||||
|
cached: BootstrapTranscriptFileIndex,
|
||||||
|
input: {
|
||||||
|
filePath: string;
|
||||||
|
statSize: number;
|
||||||
|
statMtimeMs: number;
|
||||||
|
statDev: number;
|
||||||
|
statIno: number;
|
||||||
|
}
|
||||||
|
): Promise<BootstrapTranscriptFileIndex | null> {
|
||||||
|
if (cached.filePath !== input.filePath || input.statSize <= cached.size) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle: fs.promises.FileHandle | null = null;
|
||||||
|
try {
|
||||||
|
handle = await fs.promises.open(input.filePath, 'r');
|
||||||
|
const appendSize = input.statSize - cached.size;
|
||||||
|
const buffer = Buffer.alloc(appendSize);
|
||||||
|
await handle.read(buffer, 0, buffer.length, cached.size);
|
||||||
|
const parsed = this.parseBootstrapTranscriptIndexChunk(
|
||||||
|
`${cached.partialText}${buffer.toString('utf8')}`,
|
||||||
|
cached.partialStartOffset
|
||||||
|
);
|
||||||
|
const index: BootstrapTranscriptFileIndex = {
|
||||||
|
filePath: input.filePath,
|
||||||
|
size: input.statSize,
|
||||||
|
mtimeMs: input.statMtimeMs,
|
||||||
|
dev: input.statDev,
|
||||||
|
ino: input.statIno,
|
||||||
|
partialText: parsed.partialText,
|
||||||
|
partialStartOffset: parsed.partialStartOffset,
|
||||||
|
lines: [...cached.lines, ...parsed.lines],
|
||||||
|
};
|
||||||
|
this.pruneBootstrapTranscriptFileIndex(index);
|
||||||
|
return index;
|
||||||
|
} finally {
|
||||||
|
await handle?.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseBootstrapTranscriptIndexChunk(
|
||||||
|
text: string,
|
||||||
|
offsetBase: number
|
||||||
|
): {
|
||||||
|
lines: BootstrapTranscriptIndexedLine[];
|
||||||
|
partialText: string;
|
||||||
|
partialStartOffset: number;
|
||||||
|
} {
|
||||||
|
const lines: BootstrapTranscriptIndexedLine[] = [];
|
||||||
|
const parts = text.split('\n');
|
||||||
|
const hasTrailingNewline = text.endsWith('\n');
|
||||||
|
const completeCount = parts.length - 1;
|
||||||
|
let cursor = offsetBase;
|
||||||
|
|
||||||
|
for (let index = 0; index < completeCount; index += 1) {
|
||||||
|
const rawLine = parts[index] ?? '';
|
||||||
|
const lineStart = cursor;
|
||||||
|
const lineEnd = lineStart + Buffer.byteLength(rawLine, 'utf8') + 1;
|
||||||
|
const indexed = this.parseBootstrapTranscriptIndexedLine(rawLine, lineStart, lineEnd);
|
||||||
|
if (indexed) {
|
||||||
|
lines.push(indexed);
|
||||||
|
}
|
||||||
|
cursor = lineEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalLine = hasTrailingNewline ? '' : (parts[parts.length - 1] ?? '');
|
||||||
|
if (!hasTrailingNewline && finalLine.length > 0) {
|
||||||
|
const lineStart = cursor;
|
||||||
|
const lineEnd = lineStart + Buffer.byteLength(finalLine, 'utf8');
|
||||||
|
const indexed = this.parseBootstrapTranscriptIndexedLine(finalLine, lineStart, lineEnd);
|
||||||
|
if (indexed) {
|
||||||
|
lines.push(indexed);
|
||||||
|
return { lines, partialText: '', partialStartOffset: lineEnd };
|
||||||
|
}
|
||||||
|
return { lines, partialText: finalLine, partialStartOffset: lineStart };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { lines, partialText: '', partialStartOffset: cursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseBootstrapTranscriptIndexedLine(
|
||||||
|
rawLine: string,
|
||||||
|
startOffset: number,
|
||||||
|
endOffset: number
|
||||||
|
): BootstrapTranscriptIndexedLine | null {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let parsed: { timestamp?: unknown } | null = null;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(line) as { timestamp?: unknown };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const timestampText =
|
||||||
|
typeof parsed.timestamp === 'string' && parsed.timestamp.trim().length > 0
|
||||||
|
? parsed.timestamp.trim()
|
||||||
|
: undefined;
|
||||||
|
const timestampMs = timestampText ? Date.parse(timestampText) : Number.NaN;
|
||||||
|
const agentName =
|
||||||
|
typeof (parsed as { agentName?: unknown }).agentName === 'string'
|
||||||
|
? (parsed as { agentName?: string }).agentName?.trim().toLowerCase() || null
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
startOffset,
|
||||||
|
endOffset,
|
||||||
|
timestampMs,
|
||||||
|
...(timestampText ? { timestampText } : {}),
|
||||||
|
...(agentName ? { agentName } : {}),
|
||||||
|
text: extractTranscriptMessageText(parsed),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneBootstrapTranscriptFileIndex(index: BootstrapTranscriptFileIndex): void {
|
||||||
|
const cutoff = Math.max(0, index.size - TeamProvisioningService.BOOTSTRAP_FAILURE_TAIL_BYTES);
|
||||||
|
index.lines = index.lines.filter((line) => line.startOffset >= cutoff);
|
||||||
|
if (index.partialStartOffset < cutoff) {
|
||||||
|
index.partialText = '';
|
||||||
|
index.partialStartOffset = index.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectBootstrapTranscriptOutcomeFromIndexedLines(
|
||||||
|
lines: readonly BootstrapTranscriptIndexedLine[],
|
||||||
|
input: {
|
||||||
|
sinceMs: number | null;
|
||||||
|
normalizedMemberName: string;
|
||||||
|
contextMemberNames: readonly string[];
|
||||||
|
memberName: string;
|
||||||
|
teamName: string;
|
||||||
|
allowAnonymousFailure: boolean;
|
||||||
|
}
|
||||||
|
): BootstrapTranscriptOutcome | null {
|
||||||
|
const bootstrapContextMembers = new Set<string>();
|
||||||
|
for (const line of lines) {
|
||||||
|
if (
|
||||||
|
input.sinceMs != null &&
|
||||||
|
(!Number.isFinite(line.timestampMs) || line.timestampMs < input.sinceMs)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
line.agentName &&
|
||||||
|
!matchesObservedMemberNameForExpected(line.agentName, input.normalizedMemberName)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!line.text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const contextMemberName of input.contextMemberNames) {
|
||||||
|
if (isBootstrapTranscriptContextText(line.text, input.teamName, contextMemberName)) {
|
||||||
|
bootstrapContextMembers.add(contextMemberName.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasUnambiguousMatchingBootstrapContext =
|
||||||
|
bootstrapContextMembers.size === 1 && bootstrapContextMembers.has(input.normalizedMemberName);
|
||||||
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||||
|
const line = lines[index];
|
||||||
|
if (!line) continue;
|
||||||
|
if (input.sinceMs != null) {
|
||||||
|
if (!Number.isFinite(line.timestampMs) || line.timestampMs < input.sinceMs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
line.agentName &&
|
||||||
|
!matchesObservedMemberNameForExpected(line.agentName, input.normalizedMemberName)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!line.text) continue;
|
||||||
|
const observedAt = line.timestampText ?? new Date().toISOString();
|
||||||
|
const reason = extractBootstrapFailureReason(line.text);
|
||||||
|
if (reason) {
|
||||||
|
if (
|
||||||
|
!line.agentName &&
|
||||||
|
input.allowAnonymousFailure !== true &&
|
||||||
|
!hasUnambiguousMatchingBootstrapContext
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return { kind: 'failure', observedAt, reason };
|
||||||
|
}
|
||||||
|
const successSource = getBootstrapTranscriptSuccessSource(
|
||||||
|
line.text,
|
||||||
|
input.teamName,
|
||||||
|
input.memberName
|
||||||
|
);
|
||||||
|
if (successSource) {
|
||||||
|
return { kind: 'success', observedAt, source: successSource };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scanRecentBootstrapTranscriptOutcomeFromFile(input: {
|
||||||
|
filePath: string;
|
||||||
|
statSize: number;
|
||||||
|
sinceMs: number | null;
|
||||||
|
normalizedMemberName: string;
|
||||||
|
contextMemberNames: readonly string[];
|
||||||
|
memberName: string;
|
||||||
|
teamName: string;
|
||||||
|
allowAnonymousFailure: boolean;
|
||||||
|
}): Promise<BootstrapTranscriptOutcome | null> {
|
||||||
|
let handle: fs.promises.FileHandle | null = null;
|
||||||
|
try {
|
||||||
|
handle = await fs.promises.open(input.filePath, 'r');
|
||||||
|
const start = Math.max(
|
||||||
|
0,
|
||||||
|
input.statSize - TeamProvisioningService.BOOTSTRAP_FAILURE_TAIL_BYTES
|
||||||
|
);
|
||||||
|
const buffer = Buffer.alloc(input.statSize - start);
|
||||||
if (buffer.length === 0) {
|
if (buffer.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -30063,7 +30696,10 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
const timestampMs =
|
const timestampMs =
|
||||||
typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : Number.NaN;
|
typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : Number.NaN;
|
||||||
if (sinceMs != null && (!Number.isFinite(timestampMs) || timestampMs < sinceMs)) {
|
if (
|
||||||
|
input.sinceMs != null &&
|
||||||
|
(!Number.isFinite(timestampMs) || timestampMs < input.sinceMs)
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const parsedAgentName =
|
const parsedAgentName =
|
||||||
|
|
@ -30072,7 +30708,7 @@ export class TeamProvisioningService {
|
||||||
: null;
|
: null;
|
||||||
if (
|
if (
|
||||||
parsedAgentName &&
|
parsedAgentName &&
|
||||||
!matchesObservedMemberNameForExpected(parsedAgentName, normalizedMemberName)
|
!matchesObservedMemberNameForExpected(parsedAgentName, input.normalizedMemberName)
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -30080,14 +30716,15 @@ export class TeamProvisioningService {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const contextMemberName of contextMemberNames) {
|
for (const contextMemberName of input.contextMemberNames) {
|
||||||
if (isBootstrapTranscriptContextText(text, teamName, contextMemberName)) {
|
if (isBootstrapTranscriptContextText(text, input.teamName, contextMemberName)) {
|
||||||
bootstrapContextMembers.add(contextMemberName.trim().toLowerCase());
|
bootstrapContextMembers.add(contextMemberName.trim().toLowerCase());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const hasUnambiguousMatchingBootstrapContext =
|
const hasUnambiguousMatchingBootstrapContext =
|
||||||
bootstrapContextMembers.size === 1 && bootstrapContextMembers.has(normalizedMemberName);
|
bootstrapContextMembers.size === 1 &&
|
||||||
|
bootstrapContextMembers.has(input.normalizedMemberName);
|
||||||
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;
|
||||||
|
|
@ -30099,8 +30736,8 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
const timestampMs =
|
const timestampMs =
|
||||||
typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : Number.NaN;
|
typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : Number.NaN;
|
||||||
if (sinceMs != null) {
|
if (input.sinceMs != null) {
|
||||||
if (!Number.isFinite(timestampMs) || timestampMs < sinceMs) {
|
if (!Number.isFinite(timestampMs) || timestampMs < input.sinceMs) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -30110,7 +30747,7 @@ export class TeamProvisioningService {
|
||||||
: null;
|
: null;
|
||||||
if (
|
if (
|
||||||
parsedAgentName &&
|
parsedAgentName &&
|
||||||
!matchesObservedMemberNameForExpected(parsedAgentName, normalizedMemberName)
|
!matchesObservedMemberNameForExpected(parsedAgentName, input.normalizedMemberName)
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -30124,14 +30761,18 @@ export class TeamProvisioningService {
|
||||||
if (reason) {
|
if (reason) {
|
||||||
if (
|
if (
|
||||||
!parsedAgentName &&
|
!parsedAgentName &&
|
||||||
options.allowAnonymousFailure !== true &&
|
input.allowAnonymousFailure !== true &&
|
||||||
!hasUnambiguousMatchingBootstrapContext
|
!hasUnambiguousMatchingBootstrapContext
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
return { kind: 'failure', observedAt, reason };
|
return { kind: 'failure', observedAt, reason };
|
||||||
}
|
}
|
||||||
const successSource = getBootstrapTranscriptSuccessSource(text, teamName, memberName);
|
const successSource = getBootstrapTranscriptSuccessSource(
|
||||||
|
text,
|
||||||
|
input.teamName,
|
||||||
|
input.memberName
|
||||||
|
);
|
||||||
if (successSource) {
|
if (successSource) {
|
||||||
return { kind: 'success', observedAt, source: successSource };
|
return { kind: 'success', observedAt, source: successSource };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ const logger = createLogger('Service:TeamTranscriptProjectResolver');
|
||||||
|
|
||||||
const SESSION_DISCOVERY_CACHE_TTL = 30_000;
|
const SESSION_DISCOVERY_CACHE_TTL = 30_000;
|
||||||
const TEAM_AFFINITY_SCAN_LINES = 40;
|
const TEAM_AFFINITY_SCAN_LINES = 40;
|
||||||
|
const TEAM_AFFINITY_CACHE_MAX = 20_000;
|
||||||
const ROOT_DISCOVERY_CONCURRENCY = 12;
|
const ROOT_DISCOVERY_CONCURRENCY = 12;
|
||||||
const FAST_CONTEXT_ROOT_DISCOVERY_MTIME_GRACE_MS = 24 * 60 * 60_000;
|
const FAST_CONTEXT_ROOT_DISCOVERY_MTIME_GRACE_MS = 24 * 60 * 60_000;
|
||||||
|
|
||||||
|
|
@ -57,6 +58,21 @@ interface TeamTranscriptProjectContextOptions {
|
||||||
includeTeamSubagentSessionDiscovery?: boolean;
|
includeTeamSubagentSessionDiscovery?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TeamAffinityCacheEntry {
|
||||||
|
value: boolean;
|
||||||
|
statMtimeMs: number;
|
||||||
|
statSize: number;
|
||||||
|
finalForGrowth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamAffinityObservedStat {
|
||||||
|
mtimeMs: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamAffinityCache = new Map<string, TeamAffinityCacheEntry>();
|
||||||
|
const teamAffinityInFlight = new Map<string, Promise<TeamAffinityCacheEntry>>();
|
||||||
|
|
||||||
type ScannedSessionProjectMatch = Omit<SessionProjectMatch, 'projectPath'> & {
|
type ScannedSessionProjectMatch = Omit<SessionProjectMatch, 'projectPath'> & {
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
};
|
};
|
||||||
|
|
@ -200,6 +216,17 @@ function entryContainsNestedTeamName(value: unknown, teamName: string, depth: nu
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rememberTeamAffinity(cacheKey: string, entry: TeamAffinityCacheEntry): void {
|
||||||
|
teamAffinityCache.set(cacheKey, entry);
|
||||||
|
if (teamAffinityCache.size <= TEAM_AFFINITY_CACHE_MAX) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const oldestKey = teamAffinityCache.keys().next().value;
|
||||||
|
if (oldestKey) {
|
||||||
|
teamAffinityCache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function collectKnownSessionIds(config: TeamConfig): string[] {
|
function collectKnownSessionIds(config: TeamConfig): string[] {
|
||||||
const knownSessionIds = new Set<string>();
|
const knownSessionIds = new Set<string>();
|
||||||
const push = (value: unknown): void => {
|
const push = (value: unknown): void => {
|
||||||
|
|
@ -994,12 +1021,62 @@ export class TeamTranscriptProjectResolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fileBelongsToTeam(filePath: string, teamName: string): Promise<boolean> {
|
private async fileBelongsToTeam(filePath: string, teamName: string): Promise<boolean> {
|
||||||
|
const normalizedTeam = teamName.trim().toLowerCase();
|
||||||
|
if (!normalizedTeam) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stat: Awaited<ReturnType<typeof fs.stat>>;
|
||||||
|
try {
|
||||||
|
stat = await fs.stat(filePath);
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const observedStat: TeamAffinityObservedStat = {
|
||||||
|
mtimeMs: Number(stat.mtimeMs),
|
||||||
|
size: Number(stat.size),
|
||||||
|
};
|
||||||
|
|
||||||
|
const cacheKey = `${filePath}\0${normalizedTeam}`;
|
||||||
|
const cached = teamAffinityCache.get(cacheKey);
|
||||||
|
if (
|
||||||
|
cached &&
|
||||||
|
(cached.finalForGrowth ||
|
||||||
|
(cached.statMtimeMs === observedStat.mtimeMs && cached.statSize === observedStat.size))
|
||||||
|
) {
|
||||||
|
return cached.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inFlightKey = `${cacheKey}\0${observedStat.mtimeMs}\0${observedStat.size}`;
|
||||||
|
const existing = teamAffinityInFlight.get(inFlightKey);
|
||||||
|
if (existing) {
|
||||||
|
return (await existing).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = this.scanFileBelongsToTeam(filePath, normalizedTeam, observedStat).finally(
|
||||||
|
() => {
|
||||||
|
teamAffinityInFlight.delete(inFlightKey);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
teamAffinityInFlight.set(inFlightKey, promise);
|
||||||
|
const entry = await promise;
|
||||||
|
rememberTeamAffinity(cacheKey, entry);
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scanFileBelongsToTeam(
|
||||||
|
filePath: string,
|
||||||
|
normalizedTeam: string,
|
||||||
|
stat: TeamAffinityObservedStat
|
||||||
|
): Promise<TeamAffinityCacheEntry> {
|
||||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||||
const normalizedTeam = teamName.trim().toLowerCase();
|
let inspected = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let inspected = 0;
|
|
||||||
for await (const line of rl) {
|
for await (const line of rl) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
|
|
@ -1011,15 +1088,30 @@ export class TeamTranscriptProjectResolver {
|
||||||
const entry = JSON.parse(trimmed) as Record<string, unknown>;
|
const entry = JSON.parse(trimmed) as Record<string, unknown>;
|
||||||
const directTeamName = extractDirectTeamName(entry);
|
const directTeamName = extractDirectTeamName(entry);
|
||||||
if (directTeamName === normalizedTeam) {
|
if (directTeamName === normalizedTeam) {
|
||||||
return true;
|
return {
|
||||||
|
value: true,
|
||||||
|
statMtimeMs: stat.mtimeMs,
|
||||||
|
statSize: stat.size,
|
||||||
|
finalForGrowth: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (entryContainsNestedTeamName(entry, normalizedTeam)) {
|
if (entryContainsNestedTeamName(entry, normalizedTeam)) {
|
||||||
return true;
|
return {
|
||||||
|
value: true,
|
||||||
|
statMtimeMs: stat.mtimeMs,
|
||||||
|
statSize: stat.size,
|
||||||
|
finalForGrowth: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const textContent = extractTextContent(entry);
|
const textContent = extractTextContent(entry);
|
||||||
if (textContent && lineMentionsTeam(textContent, normalizedTeam)) {
|
if (textContent && lineMentionsTeam(textContent, normalizedTeam)) {
|
||||||
return true;
|
return {
|
||||||
|
value: true,
|
||||||
|
statMtimeMs: stat.mtimeMs,
|
||||||
|
statSize: stat.size,
|
||||||
|
finalForGrowth: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore malformed head lines
|
// ignore malformed head lines
|
||||||
|
|
@ -1030,12 +1122,22 @@ export class TeamTranscriptProjectResolver {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return {
|
||||||
|
value: false,
|
||||||
|
statMtimeMs: stat.mtimeMs,
|
||||||
|
statSize: stat.size,
|
||||||
|
finalForGrowth: false,
|
||||||
|
};
|
||||||
} finally {
|
} finally {
|
||||||
rl.close();
|
rl.close();
|
||||||
stream.destroy();
|
stream.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return {
|
||||||
|
value: false,
|
||||||
|
statMtimeMs: stat.mtimeMs,
|
||||||
|
statSize: stat.size,
|
||||||
|
finalForGrowth: inspected >= TEAM_AFFINITY_SCAN_LINES,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,6 @@ export function extractHeartbeatTimestamp(text: string, fallback?: string): stri
|
||||||
export function extractBootstrapFailureReason(text: string): string | null {
|
export function extractBootstrapFailureReason(text: string): string | null {
|
||||||
const trimmed = normalizeLaunchFailureReasonText(text) ?? text.trim();
|
const trimmed = normalizeLaunchFailureReasonText(text) ?? text.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
if (isBootstrapInstructionPrompt(trimmed)) return null;
|
|
||||||
const lower = trimmed.toLowerCase();
|
const lower = trimmed.toLowerCase();
|
||||||
const looksLikeBootstrapFailure =
|
const looksLikeBootstrapFailure =
|
||||||
lower.includes('bootstrap failed') ||
|
lower.includes('bootstrap failed') ||
|
||||||
|
|
@ -240,14 +239,16 @@ export function extractBootstrapFailureReason(text: string): string | null {
|
||||||
lower.includes('not supported when using codex with a chatgpt account') ||
|
lower.includes('not supported when using codex with a chatgpt account') ||
|
||||||
lower.includes('please check the provided tool list');
|
lower.includes('please check the provided tool list');
|
||||||
if (!looksLikeBootstrapFailure) return null;
|
if (!looksLikeBootstrapFailure) return null;
|
||||||
|
if (isBootstrapInstructionPrompt(trimmed)) return null;
|
||||||
return trimmed.slice(0, 280);
|
return trimmed.slice(0, 280);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isBootstrapInstructionPrompt(text: string): boolean {
|
export function isBootstrapInstructionPrompt(text: string): boolean {
|
||||||
const normalized = text.replace(/\s+/g, ' ').trim().toLowerCase();
|
const prefix = text.trimStart().slice(0, 200).replace(/\s+/g, ' ').toLowerCase();
|
||||||
if (!normalized.startsWith('you are bootstrapping into team ')) {
|
if (!prefix.startsWith('you are bootstrapping into team ')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const normalized = text.replace(/\s+/g, ' ').trim().toLowerCase();
|
||||||
return (
|
return (
|
||||||
normalized.includes('your first action is to call the mcp tool') &&
|
normalized.includes('your first action is to call the mcp tool') &&
|
||||||
(normalized.includes('member_briefing') || normalized.includes('lead_briefing'))
|
(normalized.includes('member_briefing') || normalized.includes('lead_briefing'))
|
||||||
|
|
@ -267,16 +268,29 @@ export function getBootstrapTranscriptSuccessSource(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberName: string
|
memberName: string
|
||||||
): BootstrapTranscriptSuccessSource | null {
|
): BootstrapTranscriptSuccessSource | null {
|
||||||
const normalizedText = text.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
||||||
if (!normalizedText) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedTeamName = teamName.trim().toLowerCase();
|
const normalizedTeamName = teamName.trim().toLowerCase();
|
||||||
const normalizedMemberName = memberName.trim().toLowerCase();
|
const normalizedMemberName = memberName.trim().toLowerCase();
|
||||||
if (!normalizedTeamName || !normalizedMemberName) {
|
if (!normalizedTeamName || !normalizedMemberName) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const lowerText = text.toLowerCase();
|
||||||
|
if (
|
||||||
|
!lowerText ||
|
||||||
|
!lowerText.includes(normalizedTeamName) ||
|
||||||
|
!lowerText.includes(normalizedMemberName)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!lowerText.includes('member briefing') &&
|
||||||
|
!lowerText.includes('bootstrap выполнен') &&
|
||||||
|
!lowerText.includes('команде') &&
|
||||||
|
!lowerText.includes('briefing')
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedText = text.replace(/\s+/g, ' ').trim().toLowerCase();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
normalizedText.startsWith(
|
normalizedText.startsWith(
|
||||||
|
|
@ -300,24 +314,32 @@ export function isBootstrapTranscriptContextText(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberName: string
|
memberName: string
|
||||||
): boolean {
|
): boolean {
|
||||||
const normalizedText = text.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
||||||
const normalizedTeamName = teamName.trim().toLowerCase();
|
const normalizedTeamName = teamName.trim().toLowerCase();
|
||||||
const normalizedMemberName = memberName.trim().toLowerCase();
|
const normalizedMemberName = memberName.trim().toLowerCase();
|
||||||
if (!normalizedText || !normalizedTeamName || !normalizedMemberName) {
|
if (!normalizedTeamName || !normalizedMemberName) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const lowerText = text.toLowerCase();
|
||||||
if (
|
if (
|
||||||
!normalizedText.includes(normalizedTeamName) ||
|
!lowerText ||
|
||||||
!normalizedText.includes(normalizedMemberName)
|
!lowerText.includes(normalizedTeamName) ||
|
||||||
|
!lowerText.includes(normalizedMemberName)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return (
|
if (
|
||||||
normalizedText.includes('bootstrap') ||
|
lowerText.includes('bootstrap') ||
|
||||||
normalizedText.includes('bootstrapping') ||
|
lowerText.includes('bootstrapping') ||
|
||||||
normalizedText.includes('member briefing') ||
|
lowerText.includes('member briefing') ||
|
||||||
normalizedText.includes('task briefing')
|
lowerText.includes('task briefing')
|
||||||
);
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!lowerText.includes('briefing')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const normalizedText = lowerText.replace(/\s+/g, ' ').trim();
|
||||||
|
return normalizedText.includes('member briefing') || normalizedText.includes('task briefing');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractTranscriptTextContent(value: unknown): string[] {
|
export function extractTranscriptTextContent(value: unknown): string[] {
|
||||||
|
|
|
||||||
|
|
@ -362,7 +362,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
const TEAM_PRESENCE_REFRESH_THROTTLE_MS = 400;
|
const TEAM_PRESENCE_REFRESH_THROTTLE_MS = 400;
|
||||||
const TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500;
|
const TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500;
|
||||||
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
|
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
|
||||||
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500;
|
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 2000;
|
||||||
const PROCESS_LITE_STRUCTURAL_RECONCILE_IDLE_MS = 2_500;
|
const PROCESS_LITE_STRUCTURAL_RECONCILE_IDLE_MS = 2_500;
|
||||||
const PROCESS_LITE_STRUCTURAL_RECONCILE_MAX_WAIT_MS = 15_000;
|
const PROCESS_LITE_STRUCTURAL_RECONCILE_MAX_WAIT_MS = 15_000;
|
||||||
const buildTeamChangeFanoutReason = (eventType: string): string => `event:${eventType}`;
|
const buildTeamChangeFanoutReason = (eventType: string): string => `event:${eventType}`;
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,7 @@ const MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS = 5_000;
|
||||||
const TEAM_REFRESH_BURST_WINDOW_MS = 4_000;
|
const TEAM_REFRESH_BURST_WINDOW_MS = 4_000;
|
||||||
const MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS = 2_000;
|
const MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS = 2_000;
|
||||||
const POST_PAINT_TEAM_ENRICHMENT_FALLBACK_MS = 500;
|
const POST_PAINT_TEAM_ENRICHMENT_FALLBACK_MS = 500;
|
||||||
|
const GLOBAL_TASKS_FOLLOW_UP_REFRESH_DELAY_MS = 1_500;
|
||||||
const inFlightTeamDataRequests = new Map<string, Promise<TeamViewSnapshot>>();
|
const inFlightTeamDataRequests = new Map<string, Promise<TeamViewSnapshot>>();
|
||||||
const inFlightRefreshTeamDataCalls = new Map<string, Set<symbol>>();
|
const inFlightRefreshTeamDataCalls = new Map<string, Set<symbol>>();
|
||||||
const pendingFreshTeamDataRefreshes = new Set<string>();
|
const pendingFreshTeamDataRefreshes = new Set<string>();
|
||||||
|
|
@ -1543,6 +1544,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
const runRefresh = async (): Promise<void> => {
|
const runRefresh = async (): Promise<void> => {
|
||||||
do {
|
do {
|
||||||
|
const isFollowUpRefresh = pendingFreshGlobalTasksRefresh;
|
||||||
|
if (isFollowUpRefresh) {
|
||||||
|
await sleep(GLOBAL_TASKS_FOLLOW_UP_REFRESH_DELAY_MS);
|
||||||
|
}
|
||||||
pendingFreshGlobalTasksRefresh = false;
|
pendingFreshGlobalTasksRefresh = false;
|
||||||
|
|
||||||
// Show skeleton only on the very first fetch — not on subsequent refreshes
|
// Show skeleton only on the very first fetch — not on subsequent refreshes
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('@features/tmux-installer/main', () => ({
|
||||||
|
killTmuxPaneForCurrentPlatformSync: vi.fn(),
|
||||||
|
listRuntimeProcessTableForCurrentPlatform: vi.fn(async () => []),
|
||||||
|
listTmuxPanePidsForCurrentPlatform: vi.fn(async () => new Map()),
|
||||||
|
listTmuxPaneRuntimeInfoForCurrentPlatform: vi.fn(async () => new Map()),
|
||||||
|
sendKeysToTmuxPaneForCurrentPlatform: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('pidusage', () => ({
|
||||||
|
default: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
||||||
|
|
||||||
|
interface TranscriptIndexHarness {
|
||||||
|
bootstrapTranscriptOutcomeCache: Map<string, unknown>;
|
||||||
|
bootstrapTranscriptOutcomeInFlight: Map<string, Promise<unknown>>;
|
||||||
|
bootstrapTranscriptFileIndexByPath: Map<string, unknown>;
|
||||||
|
bootstrapTranscriptFileIndexInFlight: Map<string, Promise<unknown>>;
|
||||||
|
appendBootstrapTranscriptFileIndex: (...args: unknown[]) => Promise<unknown>;
|
||||||
|
rebuildBootstrapTranscriptFileIndex: (...args: unknown[]) => Promise<unknown>;
|
||||||
|
readRecentBootstrapTranscriptOutcome: (
|
||||||
|
filePath: string,
|
||||||
|
sinceMs: number | null,
|
||||||
|
memberName: string,
|
||||||
|
teamName: string,
|
||||||
|
options?: { allowAnonymousFailure?: boolean; contextMemberNames?: readonly string[] }
|
||||||
|
) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTranscriptIndexHarness(): TranscriptIndexHarness {
|
||||||
|
const service = Object.create(
|
||||||
|
TeamProvisioningService.prototype
|
||||||
|
) as unknown as TranscriptIndexHarness;
|
||||||
|
service.bootstrapTranscriptOutcomeCache = new Map();
|
||||||
|
service.bootstrapTranscriptOutcomeInFlight = new Map();
|
||||||
|
service.bootstrapTranscriptFileIndexByPath = new Map();
|
||||||
|
service.bootstrapTranscriptFileIndexInFlight = new Map();
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transcriptLine(input: {
|
||||||
|
timestamp: string;
|
||||||
|
agentName?: string;
|
||||||
|
text: string;
|
||||||
|
}): string {
|
||||||
|
return `${JSON.stringify({
|
||||||
|
type: 'assistant',
|
||||||
|
timestamp: input.timestamp,
|
||||||
|
...(input.agentName ? { agentName: input.agentName } : {}),
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'text', text: input.text }],
|
||||||
|
},
|
||||||
|
})}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TeamProvisioningService bootstrap transcript index', () => {
|
||||||
|
let tmpDir: string | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (tmpDir) {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
tmpDir = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the transcript outcome from appended lines using the incremental file index', async () => {
|
||||||
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bootstrap-transcript-index-'));
|
||||||
|
const transcriptPath = path.join(tmpDir, 'session.jsonl');
|
||||||
|
await fs.writeFile(
|
||||||
|
transcriptPath,
|
||||||
|
transcriptLine({
|
||||||
|
timestamp: '2026-04-18T10:00:00.000Z',
|
||||||
|
agentName: 'alice',
|
||||||
|
text: 'Member briefing for alice on team "demo-team" (demo-team).',
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = createTranscriptIndexHarness();
|
||||||
|
const originalRebuild = service.rebuildBootstrapTranscriptFileIndex.bind(service);
|
||||||
|
const originalAppend = service.appendBootstrapTranscriptFileIndex.bind(service);
|
||||||
|
let rebuildCalls = 0;
|
||||||
|
let appendCalls = 0;
|
||||||
|
service.rebuildBootstrapTranscriptFileIndex = async (...args: unknown[]) => {
|
||||||
|
rebuildCalls += 1;
|
||||||
|
return originalRebuild(...args);
|
||||||
|
};
|
||||||
|
service.appendBootstrapTranscriptFileIndex = async (...args: unknown[]) => {
|
||||||
|
appendCalls += 1;
|
||||||
|
return originalAppend(...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.readRecentBootstrapTranscriptOutcome(
|
||||||
|
transcriptPath,
|
||||||
|
null,
|
||||||
|
'alice',
|
||||||
|
'demo-team',
|
||||||
|
{ contextMemberNames: ['alice'] }
|
||||||
|
)
|
||||||
|
).resolves.toEqual({
|
||||||
|
kind: 'success',
|
||||||
|
observedAt: '2026-04-18T10:00:00.000Z',
|
||||||
|
source: 'member_briefing',
|
||||||
|
});
|
||||||
|
expect(rebuildCalls).toBe(1);
|
||||||
|
expect(appendCalls).toBe(0);
|
||||||
|
|
||||||
|
await fs.appendFile(
|
||||||
|
transcriptPath,
|
||||||
|
transcriptLine({
|
||||||
|
timestamp: '2026-04-18T10:01:00.000Z',
|
||||||
|
text: 'Bootstrap failed: member_briefing tool is not available',
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.readRecentBootstrapTranscriptOutcome(
|
||||||
|
transcriptPath,
|
||||||
|
null,
|
||||||
|
'alice',
|
||||||
|
'demo-team',
|
||||||
|
{ contextMemberNames: ['alice'] }
|
||||||
|
)
|
||||||
|
).resolves.toEqual({
|
||||||
|
kind: 'failure',
|
||||||
|
observedAt: '2026-04-18T10:01:00.000Z',
|
||||||
|
reason: 'Bootstrap failed: member_briefing tool is not available',
|
||||||
|
});
|
||||||
|
expect(rebuildCalls).toBe(1);
|
||||||
|
expect(appendCalls).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import { buildGeminiPostLaunchHydrationPrompt } from '@main/services/team/provisioning/TeamProvisioningPromptBuilders';
|
import {
|
||||||
|
buildGeminiPostLaunchHydrationPrompt,
|
||||||
|
getBootstrapTranscriptSuccessSource,
|
||||||
|
} from '@main/services/team/provisioning/TeamProvisioningPromptBuilders';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { MemberSpawnStatusEntry, TeamCreateRequest } from '@shared/types';
|
import type { MemberSpawnStatusEntry, TeamCreateRequest } from '@shared/types';
|
||||||
|
|
@ -56,4 +59,14 @@ describe('TeamProvisioningPromptBuilders', () => {
|
||||||
expect(prompt).toContain('- @tom: bootstrap confirmed');
|
expect(prompt).toContain('- @tom: bootstrap confirmed');
|
||||||
expect(prompt).not.toContain('- @tom: failed to start');
|
expect(prompt).not.toContain('- @tom: failed to start');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('recognizes bootstrap success text when member briefing is split by whitespace', () => {
|
||||||
|
expect(
|
||||||
|
getBootstrapTranscriptSuccessSource(
|
||||||
|
'Member\nbriefing for alice on team "demo-team" (demo-team).',
|
||||||
|
'demo-team',
|
||||||
|
'alice'
|
||||||
|
)
|
||||||
|
).toBe('member_briefing');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { TeamTranscriptProjectResolver } from '../../../../src/main/services/team/TeamTranscriptProjectResolver';
|
import { TeamTranscriptProjectResolver } from '../../../../src/main/services/team/TeamTranscriptProjectResolver';
|
||||||
|
|
@ -521,6 +520,48 @@ describe('TeamTranscriptProjectResolver', () => {
|
||||||
expect(context?.config.projectPath).toBe(repairedProjectPath);
|
expect(context?.config.projectPath).toBe(repairedProjectPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refreshes non-final team affinity cache when a short transcript grows', async () => {
|
||||||
|
await setupClaudeRoot();
|
||||||
|
|
||||||
|
const teamName = 'append-cache-team';
|
||||||
|
const staleProjectPath = '/Users/test/stale-project';
|
||||||
|
const repairedProjectPath = '/Users/test/repaired-project';
|
||||||
|
const staleProjectDir = path.join(tmpDir!, 'projects', encodePath(staleProjectPath));
|
||||||
|
await fs.mkdir(staleProjectDir, { recursive: true });
|
||||||
|
const repaired = await createSessionFile(repairedProjectPath, 'member-session');
|
||||||
|
|
||||||
|
await writeTeamConfig(teamName, {
|
||||||
|
name: 'Append Cache Team',
|
||||||
|
projectPath: staleProjectPath,
|
||||||
|
members: [{ name: 'alice', agentType: 'general-purpose', cwd: repairedProjectPath }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolver = new TeamTranscriptProjectResolver();
|
||||||
|
const firstContext = await resolver.getContext(teamName, { forceRefresh: true });
|
||||||
|
expect(firstContext?.projectDir).toBe(staleProjectDir);
|
||||||
|
|
||||||
|
await fs.appendFile(
|
||||||
|
repaired.jsonlPath,
|
||||||
|
`${JSON.stringify({
|
||||||
|
type: 'user',
|
||||||
|
timestamp: '2026-04-18T10:01:00.000Z',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `Current durable team context:\n- Team name: ${teamName}\n- Member: alice`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondContext = await resolver.getContext(teamName, { forceRefresh: true });
|
||||||
|
expect(secondContext?.projectDir).toBe(repaired.projectDir);
|
||||||
|
});
|
||||||
|
|
||||||
it('bounds root session discovery by team lifecycle in fast preview context', async () => {
|
it('bounds root session discovery by team lifecycle in fast preview context', async () => {
|
||||||
await setupClaudeRoot();
|
await setupClaudeRoot();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue