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;
|
||||
ppid: number;
|
||||
command: string;
|
||||
cpuPercent?: number;
|
||||
rssBytes?: number;
|
||||
}
|
||||
|
||||
export function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow[] {
|
||||
const rows: RuntimeProcessTableRow[] = [];
|
||||
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;
|
||||
|
||||
const pid = Number.parseInt(match[1], 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 (
|
||||
Number.isFinite(pid) &&
|
||||
pid > 0 &&
|
||||
|
|
@ -46,7 +50,13 @@ export function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow
|
|||
ppid >= 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;
|
||||
|
|
@ -169,7 +179,12 @@ export class TmuxPlatformCommandExecutor {
|
|||
async listRuntimeProcesses(): Promise<RuntimeProcessTableRow[]> {
|
||||
const result =
|
||||
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();
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(result.stderr || 'Failed to list runtime processes');
|
||||
|
|
@ -251,7 +266,7 @@ export class TmuxPlatformCommandExecutor {
|
|||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
'ps',
|
||||
['-ax', '-o', 'pid=,ppid=,command='],
|
||||
['-ax', '-o', 'pid=,ppid=,pcpu=,rss=,command='],
|
||||
{ env: process.env, timeout: 3_000, maxBuffer: 2 * 1024 * 1024 },
|
||||
(error, stdout, stderr) => {
|
||||
const errorCode =
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ describe('TmuxPlatformCommandExecutor', () => {
|
|||
setPlatform('win32');
|
||||
const execInPreferredDistro = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: ' 42 1 opencode runtime --team-name demo\n',
|
||||
stdout: ' 42 1 3.5 1024 opencode runtime --team-name demo\n',
|
||||
stderr: '',
|
||||
}));
|
||||
const executor = new TmuxPlatformCommandExecutor(
|
||||
|
|
@ -116,9 +116,20 @@ describe('TmuxPlatformCommandExecutor', () => {
|
|||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -687,6 +687,26 @@ type BootstrapTranscriptOutcome =
|
|||
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 {
|
||||
ActiveToolCall,
|
||||
AgentActionMode,
|
||||
|
|
@ -754,6 +774,7 @@ import type {
|
|||
// 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.
|
||||
const RUNTIME_PIDUSAGE_OPTIONS = process.platform === 'win32' ? { maxage: 10_000 } : { maxage: 0 };
|
||||
const READ_PROCESS_COMMAND_TIMEOUT_MS = 1_000;
|
||||
|
||||
interface RuntimeProcessUsageStats {
|
||||
rssBytes?: number;
|
||||
|
|
@ -3292,6 +3313,8 @@ export class TeamProvisioningService {
|
|||
private static readonly AGENT_RUNTIME_RESOURCE_HISTORY_LIMIT = 60;
|
||||
private static readonly MAX_RUNTIME_TREE_PIDS_PER_ROOT = 64;
|
||||
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_WINDOWS_PROCESS_TABLE_TIMEOUT_MS = 1_500;
|
||||
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 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 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 provisioningRunByTeam = new Map<string, string>();
|
||||
|
|
@ -3396,6 +3421,38 @@ export class TeamProvisioningService {
|
|||
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<
|
||||
string,
|
||||
{
|
||||
|
|
@ -5126,6 +5183,7 @@ export class TeamProvisioningService {
|
|||
return execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: READ_PROCESS_COMMAND_TIMEOUT_MS,
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
|
|
@ -14084,8 +14142,9 @@ export class TeamProvisioningService {
|
|||
}
|
||||
let runtimeUsageTreesByRootPid = new Map<number, { pids: number[]; truncated: boolean }>();
|
||||
let usageStatsByPid = new Map<number, RuntimeProcessUsageStats>();
|
||||
let runtimeProcessRowsForSnapshot: RuntimeTelemetryProcessTableRow[] | null = null;
|
||||
try {
|
||||
const runtimeProcessRows =
|
||||
runtimeProcessRowsForSnapshot =
|
||||
runtimeRootOwnersByPid.size > 0
|
||||
? await this.readRuntimeProcessRowsForUsageSnapshot(teamName, {
|
||||
includeWindowsHostRows: process.platform === 'win32',
|
||||
|
|
@ -14093,18 +14152,31 @@ export class TeamProvisioningService {
|
|||
: null;
|
||||
this.addRuntimeRootOwnersFromProcessRows(
|
||||
teamName,
|
||||
runtimeProcessRows,
|
||||
runtimeProcessRowsForSnapshot,
|
||||
runtimeRootOwnersByPid
|
||||
);
|
||||
runtimeUsageTreesByRootPid = this.buildRuntimeUsageProcessTrees(
|
||||
[...runtimeUsageRootPids],
|
||||
runtimeProcessRows,
|
||||
runtimeProcessRowsForSnapshot,
|
||||
runtimeRootOwnersByPid
|
||||
);
|
||||
const runtimeUsagePids = [
|
||||
...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) {
|
||||
logger.debug(
|
||||
`[${teamName}] Runtime telemetry sampling failed; continuing without resource metrics: ${
|
||||
|
|
@ -14409,6 +14481,7 @@ export class TeamProvisioningService {
|
|||
rssPid &&
|
||||
!usageStatsByPid.has(rssPid) &&
|
||||
isSharedOpenCodeHost &&
|
||||
runtimeProcessRowsForSnapshot == null &&
|
||||
typeof rssPid === 'number' &&
|
||||
rssPid > 0
|
||||
) {
|
||||
|
|
@ -25278,26 +25351,11 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
|
||||
let processRows: RuntimeTelemetryProcessTableRow[] = [];
|
||||
let processTableAvailable = true;
|
||||
try {
|
||||
processRows =
|
||||
this.normalizeRuntimeProcessRowsForTelemetry(
|
||||
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)
|
||||
}`
|
||||
);
|
||||
}
|
||||
const currentProcessRows = await this.readCurrentRuntimeProcessTableRows(
|
||||
'process table runtime snapshot'
|
||||
);
|
||||
const processRows = currentProcessRows ?? [];
|
||||
const processTableAvailable = currentProcessRows !== null;
|
||||
this.runtimeProcessRowsForUsageSnapshotByTeam.set(teamName, {
|
||||
expiresAtMs: Date.now() + TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS,
|
||||
generation: generationAtStart,
|
||||
|
|
@ -25667,6 +25725,8 @@ export class TeamProvisioningService {
|
|||
const pid = normalizeRuntimeTelemetryNumber(candidate.pid);
|
||||
const ppid = normalizeRuntimeTelemetryNumber(candidate.ppid);
|
||||
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) {
|
||||
const runtimeTelemetrySource =
|
||||
source ??
|
||||
|
|
@ -25679,6 +25739,8 @@ export class TeamProvisioningService {
|
|||
pid: Math.floor(pid),
|
||||
ppid: Math.floor(ppid),
|
||||
command,
|
||||
...(cpuPercent != null && cpuPercent >= 0 ? { cpuPercent } : {}),
|
||||
...(rssBytes != null && rssBytes >= 0 ? { rssBytes } : {}),
|
||||
...(runtimeTelemetrySource ? { runtimeTelemetrySource } : {}),
|
||||
});
|
||||
}
|
||||
|
|
@ -25686,6 +25748,69 @@ export class TeamProvisioningService {
|
|||
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(
|
||||
teamName: string,
|
||||
options: { includeWindowsHostRows?: boolean } = {}
|
||||
|
|
@ -25709,16 +25834,9 @@ export class TeamProvisioningService {
|
|||
let runtimeProcessTableAvailable = rows != null;
|
||||
try {
|
||||
if (!rows) {
|
||||
rows =
|
||||
this.normalizeRuntimeProcessRowsForTelemetry(
|
||||
await this.withRuntimeTelemetryTimeout(
|
||||
listRuntimeProcessTableForCurrentPlatform(),
|
||||
TeamProvisioningService.RUNTIME_PROCESS_TABLE_TIMEOUT_MS,
|
||||
'process table runtime telemetry'
|
||||
),
|
||||
process.platform === 'win32' ? 'wsl' : 'native'
|
||||
) ?? [];
|
||||
runtimeProcessTableAvailable = true;
|
||||
rows = await this.readCurrentRuntimeProcessTableRows('process table runtime telemetry');
|
||||
runtimeProcessTableAvailable = rows != null;
|
||||
rows = rows ?? [];
|
||||
}
|
||||
} catch (error) {
|
||||
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(
|
||||
pids: readonly number[]
|
||||
): Promise<Map<number, RuntimeProcessUsageStats>> {
|
||||
|
|
@ -26056,20 +26196,57 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
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;
|
||||
try {
|
||||
const statsByPid = await this.withRuntimeTelemetryTimeout(
|
||||
pidusage(uniquePids, options),
|
||||
pidusage(pidsToRead, options),
|
||||
TeamProvisioningService.RUNTIME_PIDUSAGE_BATCH_TIMEOUT_MS,
|
||||
'pidusage batch runtime telemetry'
|
||||
);
|
||||
const observedPids = new Set<number>();
|
||||
for (const [rawPid, stat] of Object.entries(
|
||||
statsByPid && typeof statsByPid === 'object' ? statsByPid : {}
|
||||
)) {
|
||||
const pid = Number.parseInt(rawPid, 10);
|
||||
const usageStats = this.normalizeRuntimeProcessUsageStats(stat);
|
||||
if (Number.isFinite(pid) && pid > 0 && usageStats) {
|
||||
usageStatsByPid.set(pid, usageStats);
|
||||
if (Number.isFinite(pid) && pid > 0) {
|
||||
observedPids.add(pid);
|
||||
rememberUsageStats(pid, usageStats);
|
||||
}
|
||||
}
|
||||
for (const pid of pidsToRead) {
|
||||
if (!observedPids.has(pid)) {
|
||||
rememberUsageStats(pid, null);
|
||||
}
|
||||
}
|
||||
return usageStatsByPid;
|
||||
|
|
@ -26087,10 +26264,10 @@ export class TeamProvisioningService {
|
|||
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < uniquePids.length;
|
||||
offset < pidsToRead.length;
|
||||
offset += TeamProvisioningService.RUNTIME_PIDUSAGE_FALLBACK_CONCURRENCY
|
||||
) {
|
||||
const chunk = uniquePids.slice(
|
||||
const chunk = pidsToRead.slice(
|
||||
offset,
|
||||
offset + TeamProvisioningService.RUNTIME_PIDUSAGE_FALLBACK_CONCURRENCY
|
||||
);
|
||||
|
|
@ -26103,13 +26280,12 @@ export class TeamProvisioningService {
|
|||
`pidusage runtime telemetry pid=${pid}`
|
||||
);
|
||||
const usageStats = this.normalizeRuntimeProcessUsageStats(stat);
|
||||
if (usageStats) {
|
||||
usageStatsByPid.set(pid, usageStats);
|
||||
}
|
||||
rememberUsageStats(pid, usageStats);
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeTelemetryTimeoutError) {
|
||||
logger.debug(error.message);
|
||||
}
|
||||
rememberUsageStats(pid, null);
|
||||
// Process likely exited between discovery and sampling.
|
||||
}
|
||||
})
|
||||
|
|
@ -30026,7 +30202,6 @@ export class TeamProvisioningService {
|
|||
contextMemberNames?: readonly string[];
|
||||
} = {}
|
||||
): Promise<BootstrapTranscriptOutcome | null> {
|
||||
let handle: fs.promises.FileHandle | null = null;
|
||||
const normalizedMemberName = memberName.trim().toLowerCase();
|
||||
const contextMemberNames = Array.from(
|
||||
new Set(
|
||||
|
|
@ -30035,14 +30210,472 @@ export class TeamProvisioningService {
|
|||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
let stat: Awaited<ReturnType<typeof fs.promises.stat>>;
|
||||
try {
|
||||
handle = await fs.promises.open(filePath, 'r');
|
||||
const stat = await handle.stat();
|
||||
stat = await fs.promises.stat(filePath);
|
||||
if (!stat.isFile() || stat.size <= 0) {
|
||||
return null;
|
||||
}
|
||||
const start = Math.max(0, stat.size - TeamProvisioningService.BOOTSTRAP_FAILURE_TAIL_BYTES);
|
||||
const buffer = Buffer.alloc(stat.size - start);
|
||||
} catch {
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -30063,7 +30696,10 @@ export class TeamProvisioningService {
|
|||
}
|
||||
const timestampMs =
|
||||
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;
|
||||
}
|
||||
const parsedAgentName =
|
||||
|
|
@ -30072,7 +30708,7 @@ export class TeamProvisioningService {
|
|||
: null;
|
||||
if (
|
||||
parsedAgentName &&
|
||||
!matchesObservedMemberNameForExpected(parsedAgentName, normalizedMemberName)
|
||||
!matchesObservedMemberNameForExpected(parsedAgentName, input.normalizedMemberName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -30080,14 +30716,15 @@ export class TeamProvisioningService {
|
|||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
for (const contextMemberName of contextMemberNames) {
|
||||
if (isBootstrapTranscriptContextText(text, teamName, contextMemberName)) {
|
||||
for (const contextMemberName of input.contextMemberNames) {
|
||||
if (isBootstrapTranscriptContextText(text, input.teamName, contextMemberName)) {
|
||||
bootstrapContextMembers.add(contextMemberName.trim().toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
const line = lines[index]?.trim();
|
||||
if (!line) continue;
|
||||
|
|
@ -30099,8 +30736,8 @@ export class TeamProvisioningService {
|
|||
}
|
||||
const timestampMs =
|
||||
typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : Number.NaN;
|
||||
if (sinceMs != null) {
|
||||
if (!Number.isFinite(timestampMs) || timestampMs < sinceMs) {
|
||||
if (input.sinceMs != null) {
|
||||
if (!Number.isFinite(timestampMs) || timestampMs < input.sinceMs) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
|
@ -30110,7 +30747,7 @@ export class TeamProvisioningService {
|
|||
: null;
|
||||
if (
|
||||
parsedAgentName &&
|
||||
!matchesObservedMemberNameForExpected(parsedAgentName, normalizedMemberName)
|
||||
!matchesObservedMemberNameForExpected(parsedAgentName, input.normalizedMemberName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -30124,14 +30761,18 @@ export class TeamProvisioningService {
|
|||
if (reason) {
|
||||
if (
|
||||
!parsedAgentName &&
|
||||
options.allowAnonymousFailure !== true &&
|
||||
input.allowAnonymousFailure !== true &&
|
||||
!hasUnambiguousMatchingBootstrapContext
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
return { kind: 'failure', observedAt, reason };
|
||||
}
|
||||
const successSource = getBootstrapTranscriptSuccessSource(text, teamName, memberName);
|
||||
const successSource = getBootstrapTranscriptSuccessSource(
|
||||
text,
|
||||
input.teamName,
|
||||
input.memberName
|
||||
);
|
||||
if (successSource) {
|
||||
return { kind: 'success', observedAt, source: successSource };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const logger = createLogger('Service:TeamTranscriptProjectResolver');
|
|||
|
||||
const SESSION_DISCOVERY_CACHE_TTL = 30_000;
|
||||
const TEAM_AFFINITY_SCAN_LINES = 40;
|
||||
const TEAM_AFFINITY_CACHE_MAX = 20_000;
|
||||
const ROOT_DISCOVERY_CONCURRENCY = 12;
|
||||
const FAST_CONTEXT_ROOT_DISCOVERY_MTIME_GRACE_MS = 24 * 60 * 60_000;
|
||||
|
||||
|
|
@ -57,6 +58,21 @@ interface TeamTranscriptProjectContextOptions {
|
|||
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'> & {
|
||||
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[] {
|
||||
const knownSessionIds = new Set<string>();
|
||||
const push = (value: unknown): void => {
|
||||
|
|
@ -994,12 +1021,62 @@ export class TeamTranscriptProjectResolver {
|
|||
}
|
||||
|
||||
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 rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
const normalizedTeam = teamName.trim().toLowerCase();
|
||||
let inspected = 0;
|
||||
|
||||
try {
|
||||
let inspected = 0;
|
||||
for await (const line of rl) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
|
|
@ -1011,15 +1088,30 @@ export class TeamTranscriptProjectResolver {
|
|||
const entry = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
const directTeamName = extractDirectTeamName(entry);
|
||||
if (directTeamName === normalizedTeam) {
|
||||
return true;
|
||||
return {
|
||||
value: true,
|
||||
statMtimeMs: stat.mtimeMs,
|
||||
statSize: stat.size,
|
||||
finalForGrowth: true,
|
||||
};
|
||||
}
|
||||
if (entryContainsNestedTeamName(entry, normalizedTeam)) {
|
||||
return true;
|
||||
return {
|
||||
value: true,
|
||||
statMtimeMs: stat.mtimeMs,
|
||||
statSize: stat.size,
|
||||
finalForGrowth: true,
|
||||
};
|
||||
}
|
||||
|
||||
const textContent = extractTextContent(entry);
|
||||
if (textContent && lineMentionsTeam(textContent, normalizedTeam)) {
|
||||
return true;
|
||||
return {
|
||||
value: true,
|
||||
statMtimeMs: stat.mtimeMs,
|
||||
statSize: stat.size,
|
||||
finalForGrowth: true,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed head lines
|
||||
|
|
@ -1030,12 +1122,22 @@ export class TeamTranscriptProjectResolver {
|
|||
}
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
return {
|
||||
value: false,
|
||||
statMtimeMs: stat.mtimeMs,
|
||||
statSize: stat.size,
|
||||
finalForGrowth: false,
|
||||
};
|
||||
} finally {
|
||||
rl.close();
|
||||
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 {
|
||||
const trimmed = normalizeLaunchFailureReasonText(text) ?? text.trim();
|
||||
if (!trimmed) return null;
|
||||
if (isBootstrapInstructionPrompt(trimmed)) return null;
|
||||
const lower = trimmed.toLowerCase();
|
||||
const looksLikeBootstrapFailure =
|
||||
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('please check the provided tool list');
|
||||
if (!looksLikeBootstrapFailure) return null;
|
||||
if (isBootstrapInstructionPrompt(trimmed)) return null;
|
||||
return trimmed.slice(0, 280);
|
||||
}
|
||||
|
||||
export function isBootstrapInstructionPrompt(text: string): boolean {
|
||||
const normalized = text.replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
if (!normalized.startsWith('you are bootstrapping into team ')) {
|
||||
const prefix = text.trimStart().slice(0, 200).replace(/\s+/g, ' ').toLowerCase();
|
||||
if (!prefix.startsWith('you are bootstrapping into team ')) {
|
||||
return false;
|
||||
}
|
||||
const normalized = text.replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
return (
|
||||
normalized.includes('your first action is to call the mcp tool') &&
|
||||
(normalized.includes('member_briefing') || normalized.includes('lead_briefing'))
|
||||
|
|
@ -267,16 +268,29 @@ export function getBootstrapTranscriptSuccessSource(
|
|||
teamName: string,
|
||||
memberName: string
|
||||
): BootstrapTranscriptSuccessSource | null {
|
||||
const normalizedText = text.replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
if (!normalizedText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedTeamName = teamName.trim().toLowerCase();
|
||||
const normalizedMemberName = memberName.trim().toLowerCase();
|
||||
if (!normalizedTeamName || !normalizedMemberName) {
|
||||
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 (
|
||||
normalizedText.startsWith(
|
||||
|
|
@ -300,24 +314,32 @@ export function isBootstrapTranscriptContextText(
|
|||
teamName: string,
|
||||
memberName: string
|
||||
): boolean {
|
||||
const normalizedText = text.replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
const normalizedTeamName = teamName.trim().toLowerCase();
|
||||
const normalizedMemberName = memberName.trim().toLowerCase();
|
||||
if (!normalizedText || !normalizedTeamName || !normalizedMemberName) {
|
||||
if (!normalizedTeamName || !normalizedMemberName) {
|
||||
return false;
|
||||
}
|
||||
const lowerText = text.toLowerCase();
|
||||
if (
|
||||
!normalizedText.includes(normalizedTeamName) ||
|
||||
!normalizedText.includes(normalizedMemberName)
|
||||
!lowerText ||
|
||||
!lowerText.includes(normalizedTeamName) ||
|
||||
!lowerText.includes(normalizedMemberName)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
normalizedText.includes('bootstrap') ||
|
||||
normalizedText.includes('bootstrapping') ||
|
||||
normalizedText.includes('member briefing') ||
|
||||
normalizedText.includes('task briefing')
|
||||
);
|
||||
if (
|
||||
lowerText.includes('bootstrap') ||
|
||||
lowerText.includes('bootstrapping') ||
|
||||
lowerText.includes('member 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[] {
|
||||
|
|
|
|||
|
|
@ -362,7 +362,7 @@ export function initializeNotificationListeners(): () => void {
|
|||
const TEAM_PRESENCE_REFRESH_THROTTLE_MS = 400;
|
||||
const TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500;
|
||||
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_MAX_WAIT_MS = 15_000;
|
||||
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 MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS = 2_000;
|
||||
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 inFlightRefreshTeamDataCalls = new Map<string, Set<symbol>>();
|
||||
const pendingFreshTeamDataRefreshes = new Set<string>();
|
||||
|
|
@ -1543,6 +1544,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
|
||||
const runRefresh = async (): Promise<void> => {
|
||||
do {
|
||||
const isFollowUpRefresh = pendingFreshGlobalTasksRefresh;
|
||||
if (isFollowUpRefresh) {
|
||||
await sleep(GLOBAL_TASKS_FOLLOW_UP_REFRESH_DELAY_MS);
|
||||
}
|
||||
pendingFreshGlobalTasksRefresh = false;
|
||||
|
||||
// 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 type { MemberSpawnStatusEntry, TeamCreateRequest } from '@shared/types';
|
||||
|
|
@ -56,4 +59,14 @@ describe('TeamProvisioningPromptBuilders', () => {
|
|||
expect(prompt).toContain('- @tom: bootstrap confirmed');
|
||||
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 os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TeamTranscriptProjectResolver } from '../../../../src/main/services/team/TeamTranscriptProjectResolver';
|
||||
|
|
@ -521,6 +520,48 @@ describe('TeamTranscriptProjectResolver', () => {
|
|||
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 () => {
|
||||
await setupClaudeRoot();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue