perf: reduce team page runtime telemetry load
This commit is contained in:
parent
6eada589aa
commit
14f25fdc81
4 changed files with 317 additions and 31 deletions
|
|
@ -807,13 +807,18 @@ 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;
|
const READ_PROCESS_COMMAND_TIMEOUT_MS = 300;
|
||||||
|
|
||||||
interface RuntimeProcessUsageStats {
|
interface RuntimeProcessUsageStats {
|
||||||
rssBytes?: number;
|
rssBytes?: number;
|
||||||
cpuPercent?: number;
|
cpuPercent?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RuntimeProcessCommandCacheEntry {
|
||||||
|
expiresAtMs: number;
|
||||||
|
command: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface RuntimeProcessLoadStats extends RuntimeProcessUsageStats {
|
interface RuntimeProcessLoadStats extends RuntimeProcessUsageStats {
|
||||||
primaryCpuPercent?: number;
|
primaryCpuPercent?: number;
|
||||||
primaryRssBytes?: number;
|
primaryRssBytes?: number;
|
||||||
|
|
@ -3347,7 +3352,10 @@ export class TeamProvisioningService {
|
||||||
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_TABLE_CACHE_TTL_MS = 2_000;
|
||||||
private static readonly RUNTIME_PROCESS_USAGE_CACHE_TTL_MS = 2_000;
|
private static readonly RUNTIME_PROCESS_USAGE_CACHE_TTL_MS = 30_000;
|
||||||
|
private static readonly RUNTIME_PROCESS_COMMAND_CACHE_TTL_MS = 10_000;
|
||||||
|
private static readonly RUNTIME_PROCESS_COMMAND_MISS_CACHE_TTL_MS = 15_000;
|
||||||
|
private static readonly RUNTIME_PROCESS_COMMAND_CACHE_MAX = 2_048;
|
||||||
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;
|
||||||
|
|
@ -3471,6 +3479,10 @@ export class TeamProvisioningService {
|
||||||
stats: RuntimeProcessUsageStats | null;
|
stats: RuntimeProcessUsageStats | null;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
private readonly runtimeProcessCommandCacheByPid = new Map<
|
||||||
|
number,
|
||||||
|
RuntimeProcessCommandCacheEntry
|
||||||
|
>();
|
||||||
private readonly bootstrapTranscriptOutcomeCache = new Map<
|
private readonly bootstrapTranscriptOutcomeCache = new Map<
|
||||||
string,
|
string,
|
||||||
BootstrapTranscriptOutcome | null
|
BootstrapTranscriptOutcome | null
|
||||||
|
|
@ -5307,6 +5319,55 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readCachedProcessCommandByPid(pid: number): string | null {
|
||||||
|
if (!Number.isFinite(pid) || pid <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const cached = this.runtimeProcessCommandCacheByPid.get(pid);
|
||||||
|
if (cached && cached.expiresAtMs > now) {
|
||||||
|
return cached.command;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = this.readProcessCommandByPid(pid);
|
||||||
|
const normalizedCommand = command?.trim() || null;
|
||||||
|
const ttlMs = normalizedCommand
|
||||||
|
? TeamProvisioningService.RUNTIME_PROCESS_COMMAND_CACHE_TTL_MS
|
||||||
|
: TeamProvisioningService.RUNTIME_PROCESS_COMMAND_MISS_CACHE_TTL_MS;
|
||||||
|
this.rememberRuntimeProcessCommand(pid, normalizedCommand, now + ttlMs);
|
||||||
|
return normalizedCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rememberRuntimeProcessCommand(
|
||||||
|
pid: number,
|
||||||
|
command: string | null,
|
||||||
|
expiresAtMs: number
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
this.runtimeProcessCommandCacheByPid.size >=
|
||||||
|
TeamProvisioningService.RUNTIME_PROCESS_COMMAND_CACHE_MAX
|
||||||
|
) {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [cachedPid, cached] of this.runtimeProcessCommandCacheByPid) {
|
||||||
|
if (cached.expiresAtMs <= now) {
|
||||||
|
this.runtimeProcessCommandCacheByPid.delete(cachedPid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (
|
||||||
|
this.runtimeProcessCommandCacheByPid.size >=
|
||||||
|
TeamProvisioningService.RUNTIME_PROCESS_COMMAND_CACHE_MAX
|
||||||
|
) {
|
||||||
|
const oldestPid = this.runtimeProcessCommandCacheByPid.keys().next().value;
|
||||||
|
if (oldestPid === undefined) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.runtimeProcessCommandCacheByPid.delete(oldestPid);
|
||||||
|
}
|
||||||
|
this.runtimeProcessCommandCacheByPid.set(pid, { expiresAtMs, command });
|
||||||
|
}
|
||||||
|
|
||||||
private isOpenCodeServeCommand(command: string): boolean {
|
private isOpenCodeServeCommand(command: string): boolean {
|
||||||
return /(^|[/\\\s])opencode(?:\.exe)?(\s|$)/i.test(command) && /\sserve(\s|$)/i.test(command);
|
return /(^|[/\\\s])opencode(?:\.exe)?(\s|$)/i.test(command) && /\sserve(\s|$)/i.test(command);
|
||||||
}
|
}
|
||||||
|
|
@ -14646,7 +14707,6 @@ export class TeamProvisioningService {
|
||||||
rssPid > 0
|
rssPid > 0
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
this.runtimeProcessUsageStatsCacheByPid.delete(rssPid);
|
|
||||||
const refreshedUsageStats = (await this.readProcessUsageStatsByPid([rssPid])).get(rssPid);
|
const refreshedUsageStats = (await this.readProcessUsageStatsByPid([rssPid])).get(rssPid);
|
||||||
if (refreshedUsageStats) {
|
if (refreshedUsageStats) {
|
||||||
usageStatsByPid.set(rssPid, refreshedUsageStats);
|
usageStatsByPid.set(rssPid, refreshedUsageStats);
|
||||||
|
|
@ -25938,9 +25998,13 @@ export class TeamProvisioningService {
|
||||||
runtimePid: targetedRuntimePid,
|
runtimePid: targetedRuntimePid,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
|
const shouldUseTargetedDirectPidRead =
|
||||||
|
!memberProcessTableAvailable || memberProcessRows.length === 0;
|
||||||
const targetedCommand =
|
const targetedCommand =
|
||||||
this.findRuntimeProcessCommandByPid(memberProcessRows, targetedRuntimePid) ??
|
this.findRuntimeProcessCommandByPid(memberProcessRows, targetedRuntimePid) ??
|
||||||
this.readProcessCommandByPid(targetedRuntimePid);
|
(shouldUseTargetedDirectPidRead
|
||||||
|
? this.readCachedProcessCommandByPid(targetedRuntimePid)
|
||||||
|
: null);
|
||||||
if (targetedCommand) {
|
if (targetedCommand) {
|
||||||
resolved = resolveTeamMemberRuntimeLiveness({
|
resolved = resolveTeamMemberRuntimeLiveness({
|
||||||
...livenessInput,
|
...livenessInput,
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,24 @@ const provisioningHarness = vi.hoisted(() => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type SuggestionHookOptions = {
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const suggestionHarness = vi.hoisted(() => {
|
||||||
|
const state = {
|
||||||
|
taskOptions: [] as SuggestionHookOptions[],
|
||||||
|
teamOptions: [] as SuggestionHookOptions[],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
reset: () => {
|
||||||
|
state.taskOptions = [];
|
||||||
|
state.teamOptions = [];
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const storeHarness = vi.hoisted(() => {
|
const storeHarness = vi.hoisted(() => {
|
||||||
const state = {
|
const state = {
|
||||||
crossTeamTargets: [] as {
|
crossTeamTargets: [] as {
|
||||||
|
|
@ -83,9 +101,16 @@ const storeHarness = vi.hoisted(() => {
|
||||||
isOnline?: boolean;
|
isOnline?: boolean;
|
||||||
}[],
|
}[],
|
||||||
};
|
};
|
||||||
|
const methods = {
|
||||||
|
fetchCrossTeamTargets: vi.fn(),
|
||||||
|
fetchSkillsCatalog: vi.fn(),
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
|
methods,
|
||||||
reset: () => {
|
reset: () => {
|
||||||
state.crossTeamTargets = [];
|
state.crossTeamTargets = [];
|
||||||
|
methods.fetchCrossTeamTargets.mockClear();
|
||||||
|
methods.fetchSkillsCatalog.mockClear();
|
||||||
},
|
},
|
||||||
state,
|
state,
|
||||||
};
|
};
|
||||||
|
|
@ -129,14 +154,18 @@ vi.mock('@renderer/components/ui/MentionableTextarea', () => {
|
||||||
cornerAction?: React.ReactNode;
|
cornerAction?: React.ReactNode;
|
||||||
cornerActionLeft?: React.ReactNode;
|
cornerActionLeft?: React.ReactNode;
|
||||||
footerRight?: React.ReactNode;
|
footerRight?: React.ReactNode;
|
||||||
|
onBlur?: React.FocusEventHandler<HTMLTextAreaElement>;
|
||||||
|
onFocus?: React.FocusEventHandler<HTMLTextAreaElement>;
|
||||||
}
|
}
|
||||||
>(({ value, disabled, cornerAction, cornerActionLeft, footerRight }, ref) =>
|
>(({ value, disabled, cornerAction, cornerActionLeft, footerRight, onBlur, onFocus }, ref) =>
|
||||||
React.createElement(
|
React.createElement(
|
||||||
'div',
|
'div',
|
||||||
null,
|
null,
|
||||||
React.createElement('textarea', {
|
React.createElement('textarea', {
|
||||||
'aria-label': 'Message',
|
'aria-label': 'Message',
|
||||||
disabled,
|
disabled,
|
||||||
|
onBlur,
|
||||||
|
onFocus,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
ref,
|
ref,
|
||||||
value,
|
value,
|
||||||
|
|
@ -198,19 +227,25 @@ vi.mock('@renderer/hooks/useComposerDraft', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/hooks/useTaskSuggestions', () => ({
|
vi.mock('@renderer/hooks/useTaskSuggestions', () => ({
|
||||||
useTaskSuggestions: () => ({ suggestions: [] }),
|
useTaskSuggestions: (_teamName: string | null, options: SuggestionHookOptions = {}) => {
|
||||||
|
suggestionHarness.state.taskOptions.push(options);
|
||||||
|
return { suggestions: [] };
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/hooks/useTeamSuggestions', () => ({
|
vi.mock('@renderer/hooks/useTeamSuggestions', () => ({
|
||||||
useTeamSuggestions: () => ({ suggestions: [] }),
|
useTeamSuggestions: (_teamName: string | null, options: SuggestionHookOptions = {}) => {
|
||||||
|
suggestionHarness.state.teamOptions.push(options);
|
||||||
|
return { suggestions: [] };
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/store', () => ({
|
vi.mock('@renderer/store', () => ({
|
||||||
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||||
selector({
|
selector({
|
||||||
crossTeamTargets: storeHarness.state.crossTeamTargets,
|
crossTeamTargets: storeHarness.state.crossTeamTargets,
|
||||||
fetchCrossTeamTargets: vi.fn(),
|
fetchCrossTeamTargets: storeHarness.methods.fetchCrossTeamTargets,
|
||||||
fetchSkillsCatalog: vi.fn(),
|
fetchSkillsCatalog: storeHarness.methods.fetchSkillsCatalog,
|
||||||
selectedTeamData: null,
|
selectedTeamData: null,
|
||||||
selectedTeamName: null,
|
selectedTeamName: null,
|
||||||
skillsProjectCatalogByProjectPath: {},
|
skillsProjectCatalogByProjectPath: {},
|
||||||
|
|
@ -312,6 +347,7 @@ describe('MessageComposer pending send lifecycle', () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
draftHarness.reset();
|
draftHarness.reset();
|
||||||
provisioningHarness.reset();
|
provisioningHarness.reset();
|
||||||
|
suggestionHarness.reset();
|
||||||
storeHarness.reset();
|
storeHarness.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -649,4 +685,47 @@ describe('MessageComposer pending send lifecycle', () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defers expensive mention data until the matching trigger is typed', () => {
|
||||||
|
draftHarness.state.text = '';
|
||||||
|
const { host, render, root } = renderComposer();
|
||||||
|
|
||||||
|
expect(suggestionHarness.state.taskOptions.at(-1)?.enabled).toBe(false);
|
||||||
|
expect(suggestionHarness.state.teamOptions.at(-1)?.enabled).toBe(false);
|
||||||
|
expect(storeHarness.methods.fetchSkillsCatalog).not.toHaveBeenCalled();
|
||||||
|
expect(storeHarness.methods.fetchCrossTeamTargets).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
getTextarea(host).focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(suggestionHarness.state.taskOptions.at(-1)?.enabled).toBe(false);
|
||||||
|
expect(suggestionHarness.state.teamOptions.at(-1)?.enabled).toBe(false);
|
||||||
|
expect(storeHarness.methods.fetchSkillsCatalog).not.toHaveBeenCalled();
|
||||||
|
expect(storeHarness.methods.fetchCrossTeamTargets).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
draftHarness.state.text = '#';
|
||||||
|
render();
|
||||||
|
|
||||||
|
expect(suggestionHarness.state.taskOptions.at(-1)?.enabled).toBe(true);
|
||||||
|
expect(suggestionHarness.state.teamOptions.at(-1)?.enabled).toBe(false);
|
||||||
|
expect(storeHarness.methods.fetchSkillsCatalog).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
draftHarness.state.text = '@';
|
||||||
|
render();
|
||||||
|
|
||||||
|
expect(suggestionHarness.state.taskOptions.at(-1)?.enabled).toBe(false);
|
||||||
|
expect(suggestionHarness.state.teamOptions.at(-1)?.enabled).toBe(true);
|
||||||
|
expect(storeHarness.methods.fetchSkillsCatalog).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
draftHarness.state.text = '/';
|
||||||
|
render();
|
||||||
|
|
||||||
|
expect(storeHarness.methods.fetchSkillsCatalog).toHaveBeenCalledTimes(1);
|
||||||
|
expect(storeHarness.methods.fetchCrossTeamTargets).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,8 @@ let pendingSendIdCounter = 0;
|
||||||
const FLOATING_COMPOSER_MIN_WIDTH = 350;
|
const FLOATING_COMPOSER_MIN_WIDTH = 350;
|
||||||
const FLOATING_COMPOSER_MAX_WIDTH = 500;
|
const FLOATING_COMPOSER_MAX_WIDTH = 500;
|
||||||
const FLOATING_COMPOSER_TEXT_BUFFER = 4;
|
const FLOATING_COMPOSER_TEXT_BUFFER = 4;
|
||||||
|
const EMPTY_MENTION_SUGGESTIONS: MentionSuggestion[] = [];
|
||||||
|
const EMPTY_SKILL_CATALOG = [] as const;
|
||||||
|
|
||||||
function createPendingSendId(): string {
|
function createPendingSendId(): string {
|
||||||
const randomId = globalThis.crypto?.randomUUID?.();
|
const randomId = globalThis.crypto?.randomUUID?.();
|
||||||
|
|
@ -189,13 +191,10 @@ export const MessageComposer = ({
|
||||||
const [selectedTeam, setSelectedTeam] = useState<string | null>(null);
|
const [selectedTeam, setSelectedTeam] = useState<string | null>(null);
|
||||||
const [teamSelectorOpen, setTeamSelectorOpen] = useState(false);
|
const [teamSelectorOpen, setTeamSelectorOpen] = useState(false);
|
||||||
const [aliveTeams, setAliveTeams] = useState<Set<string>>(new Set());
|
const [aliveTeams, setAliveTeams] = useState<Set<string>>(new Set());
|
||||||
|
const crossTeamTargetsFetchedRef = useRef(false);
|
||||||
const allCrossTeamTargets = useStore(useShallow((s) => s.crossTeamTargets));
|
const allCrossTeamTargets = useStore(useShallow((s) => s.crossTeamTargets));
|
||||||
const fetchCrossTeamTargets = useStore((s) => s.fetchCrossTeamTargets);
|
const fetchCrossTeamTargets = useStore((s) => s.fetchCrossTeamTargets);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void fetchCrossTeamTargets();
|
|
||||||
}, [fetchCrossTeamTargets]);
|
|
||||||
|
|
||||||
const refreshAliveTeams = useCallback(async () => {
|
const refreshAliveTeams = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const list = await api.teams.aliveList();
|
const list = await api.teams.aliveList();
|
||||||
|
|
@ -205,14 +204,14 @@ export const MessageComposer = ({
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void refreshAliveTeams();
|
|
||||||
}, [refreshAliveTeams]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!teamSelectorOpen) return;
|
if (!teamSelectorOpen) return;
|
||||||
|
if (!crossTeamTargetsFetchedRef.current) {
|
||||||
|
crossTeamTargetsFetchedRef.current = true;
|
||||||
|
void fetchCrossTeamTargets();
|
||||||
|
}
|
||||||
void refreshAliveTeams();
|
void refreshAliveTeams();
|
||||||
}, [teamSelectorOpen, refreshAliveTeams]);
|
}, [fetchCrossTeamTargets, refreshAliveTeams, teamSelectorOpen]);
|
||||||
|
|
||||||
// Always filter out current team on the UI side (store is global, shared across tabs)
|
// Always filter out current team on the UI side (store is global, shared across tabs)
|
||||||
const crossTeamTargets = useMemo(
|
const crossTeamTargets = useMemo(
|
||||||
|
|
@ -273,6 +272,15 @@ export const MessageComposer = ({
|
||||||
const isProvisioning = useStore((s) => isTeamProvisioningActive(s, teamName));
|
const isProvisioning = useStore((s) => isTeamProvisioningActive(s, teamName));
|
||||||
const draft = useComposerDraft(teamName);
|
const draft = useComposerDraft(teamName);
|
||||||
const appliedRevisionRequestIdRef = useRef<string | null>(null);
|
const appliedRevisionRequestIdRef = useRef<string | null>(null);
|
||||||
|
const textHasTeamMentionTrigger = draft.text.includes('@');
|
||||||
|
const textHasTaskMentionTrigger = draft.text.includes('#');
|
||||||
|
const textHasSlashCommandTrigger = stripEncodedTaskReferenceMetadata(draft.text)
|
||||||
|
.trimStart()
|
||||||
|
.startsWith('/');
|
||||||
|
const taskSuggestionDataEnabled =
|
||||||
|
textHasTaskMentionTrigger || draft.chips.length > 0 || revisionRequest != null;
|
||||||
|
const teamSuggestionDataEnabled = textHasTeamMentionTrigger;
|
||||||
|
const slashCommandDataEnabled = textHasSlashCommandTrigger;
|
||||||
|
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
|
|
||||||
|
|
@ -293,30 +301,43 @@ export const MessageComposer = ({
|
||||||
);
|
);
|
||||||
}, [members]);
|
}, [members]);
|
||||||
|
|
||||||
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
|
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName, {
|
||||||
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
|
enabled: teamSuggestionDataEnabled,
|
||||||
|
});
|
||||||
|
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName, {
|
||||||
|
enabled: taskSuggestionDataEnabled,
|
||||||
|
});
|
||||||
// Project skills as slash command suggestions
|
// Project skills as slash command suggestions
|
||||||
const projectSkills = useStore(
|
const projectSkills = useStore(
|
||||||
useShallow((s) => (projectPath ? (s.skillsProjectCatalogByProjectPath[projectPath] ?? []) : []))
|
useShallow((s) =>
|
||||||
|
slashCommandDataEnabled && projectPath
|
||||||
|
? (s.skillsProjectCatalogByProjectPath[projectPath] ?? EMPTY_SKILL_CATALOG)
|
||||||
|
: EMPTY_SKILL_CATALOG
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const userSkills = useStore(
|
||||||
|
useShallow((s) => (slashCommandDataEnabled ? s.skillsUserCatalog : EMPTY_SKILL_CATALOG))
|
||||||
);
|
);
|
||||||
const userSkills = useStore(useShallow((s) => s.skillsUserCatalog));
|
|
||||||
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
|
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
|
||||||
const isLaunchBlocking = isProvisioning && !isTeamAlive;
|
const isLaunchBlocking = isProvisioning && !isTeamAlive;
|
||||||
|
|
||||||
// Fetch skills catalog for the team's project on mount / project change
|
// Fetch the catalog only when slash suggestions are actually needed.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!slashCommandDataEnabled) return;
|
||||||
void fetchSkillsCatalog(projectPath ?? undefined);
|
void fetchSkillsCatalog(projectPath ?? undefined);
|
||||||
}, [fetchSkillsCatalog, projectPath]);
|
}, [fetchSkillsCatalog, projectPath, slashCommandDataEnabled]);
|
||||||
|
|
||||||
const slashCommandSuggestions = useMemo<MentionSuggestion[]>(
|
const slashCommandSuggestions = useMemo<MentionSuggestion[]>(
|
||||||
() =>
|
() =>
|
||||||
buildSlashCommandSuggestions(
|
slashCommandDataEnabled
|
||||||
getSuggestedSlashCommandsForProvider(leadProviderId),
|
? buildSlashCommandSuggestions(
|
||||||
projectSkills,
|
getSuggestedSlashCommandsForProvider(leadProviderId),
|
||||||
userSkills,
|
projectSkills,
|
||||||
leadProviderId
|
userSkills,
|
||||||
),
|
leadProviderId
|
||||||
[leadProviderId, projectSkills, userSkills]
|
)
|
||||||
|
: EMPTY_MENTION_SUGGESTIONS,
|
||||||
|
[leadProviderId, projectSkills, slashCommandDataEnabled, userSkills]
|
||||||
);
|
);
|
||||||
|
|
||||||
const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim();
|
const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim();
|
||||||
|
|
|
||||||
|
|
@ -613,6 +613,11 @@ type TeamProvisioningServicePrivateHarness = {
|
||||||
...segments: string[]
|
...segments: string[]
|
||||||
) => string;
|
) => string;
|
||||||
mergeAndRemoveDuplicateInboxes: (teamName: string, baseNames: Set<string>) => Promise<void>;
|
mergeAndRemoveDuplicateInboxes: (teamName: string, baseNames: Set<string>) => Promise<void>;
|
||||||
|
readProcessCommandByPid: (pid: number) => string | null;
|
||||||
|
readCachedProcessCommandByPid: (pid: number) => string | null;
|
||||||
|
readProcessUsageStatsByPid: (
|
||||||
|
pids: readonly number[]
|
||||||
|
) => Promise<Map<number, { rssBytes?: number; cpuPercent?: number }>>;
|
||||||
getLiveTeamAgentRuntimeMetadata: (
|
getLiveTeamAgentRuntimeMetadata: (
|
||||||
teamName: string
|
teamName: string
|
||||||
) => Promise<Map<string, Record<string, unknown>>>;
|
) => Promise<Map<string, Record<string, unknown>>>;
|
||||||
|
|
@ -629,6 +634,10 @@ type TeamProvisioningServicePrivateHarness = {
|
||||||
) => Record<string, unknown>;
|
) => Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TeamProvisioningServiceRuntimeCommandCacheStatics = {
|
||||||
|
RUNTIME_PROCESS_COMMAND_MISS_CACHE_TTL_MS: number;
|
||||||
|
};
|
||||||
|
|
||||||
function privateHarness(svc: TeamProvisioningService): TeamProvisioningServicePrivateHarness {
|
function privateHarness(svc: TeamProvisioningService): TeamProvisioningServicePrivateHarness {
|
||||||
return svc as unknown as TeamProvisioningServicePrivateHarness;
|
return svc as unknown as TeamProvisioningServicePrivateHarness;
|
||||||
}
|
}
|
||||||
|
|
@ -4890,6 +4899,23 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(stats.get(333)).toEqual({ rssBytes: 123_000_000, cpuPercent: 7 });
|
expect(stats.get(333)).toEqual({ rssBytes: 123_000_000, cpuPercent: 7 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('caches runtime process usage stats for repeated reads', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const usageByPid: Record<string, ReturnType<typeof createPidusageStat>> = {
|
||||||
|
'111': createPidusageStat(111, 123_000_000, 7),
|
||||||
|
};
|
||||||
|
vi.mocked(pidusage).mockResolvedValueOnce(usageByPid);
|
||||||
|
|
||||||
|
const harness = privateHarness(svc);
|
||||||
|
const first = await harness.readProcessUsageStatsByPid([111]);
|
||||||
|
const second = await harness.readProcessUsageStatsByPid([111]);
|
||||||
|
|
||||||
|
expect(pidusage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(pidusage).toHaveBeenCalledWith([111], EXPECTED_RUNTIME_PIDUSAGE_OPTIONS);
|
||||||
|
expect(first.get(111)).toEqual({ rssBytes: 123_000_000, cpuPercent: 7 });
|
||||||
|
expect(second.get(111)).toEqual({ rssBytes: 123_000_000, cpuPercent: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to direct agent process lookup when tmux pane pid lookup is unavailable', async () => {
|
it('falls back to direct agent process lookup when tmux pane pid lookup is unavailable', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
|
|
@ -5652,6 +5678,102 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not run targeted pid verification when a non-empty process table misses the pid', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
(svc as any).configReader = {
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', agentType: 'team-lead' },
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.4-mini',
|
||||||
|
agentId: 'alice@vector-room-13',
|
||||||
|
backendType: 'process',
|
||||||
|
runtimePid: 74735,
|
||||||
|
tmuxPaneId: 'process:74735',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
(svc as any).membersMetaStore = {
|
||||||
|
getMembers: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.4-mini',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
(svc as any).readPersistedRuntimeMembers = vi.fn(() => [
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.4-mini',
|
||||||
|
agentId: 'alice@vector-room-13',
|
||||||
|
backendType: 'process',
|
||||||
|
runtimePid: 74735,
|
||||||
|
tmuxPaneId: 'process:74735',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.mocked(listRuntimeProcessTableForCurrentPlatform).mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
pid: 111,
|
||||||
|
ppid: 1,
|
||||||
|
command: '/usr/bin/other-process',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const targetedRead = vi.spyOn(svc as any, 'readProcessCommandByPid').mockReturnValue(
|
||||||
|
'/Users/belief/.bun/bin/bun cli.js --agent-id alice@vector-room-13 --agent-name alice --team-name vector-room-13 --model gpt-5.4-mini'
|
||||||
|
);
|
||||||
|
|
||||||
|
const metadata = await (svc as any).getLiveTeamAgentRuntimeMetadata('vector-room-13');
|
||||||
|
|
||||||
|
expect(targetedRead).not.toHaveBeenCalled();
|
||||||
|
expect(metadata.get('alice')?.livenessKind).not.toBe('runtime_process');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caches targeted process command reads for liveness checks', () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const harness = privateHarness(svc);
|
||||||
|
const directRead = vi
|
||||||
|
.spyOn(harness, 'readProcessCommandByPid')
|
||||||
|
.mockReturnValue('/usr/bin/codex --agent-id alice@runtime-team');
|
||||||
|
|
||||||
|
expect(harness.readCachedProcessCommandByPid(74735)).toBe(
|
||||||
|
'/usr/bin/codex --agent-id alice@runtime-team'
|
||||||
|
);
|
||||||
|
expect(harness.readCachedProcessCommandByPid(74735)).toBe(
|
||||||
|
'/usr/bin/codex --agent-id alice@runtime-team'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(directRead).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expires cached targeted process command misses quickly', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const harness = privateHarness(svc);
|
||||||
|
const runtimeCommandCacheStatics =
|
||||||
|
TeamProvisioningService as unknown as TeamProvisioningServiceRuntimeCommandCacheStatics;
|
||||||
|
const originalMissTtl = runtimeCommandCacheStatics.RUNTIME_PROCESS_COMMAND_MISS_CACHE_TTL_MS;
|
||||||
|
runtimeCommandCacheStatics.RUNTIME_PROCESS_COMMAND_MISS_CACHE_TTL_MS = 25;
|
||||||
|
const directRead = vi.spyOn(harness, 'readProcessCommandByPid').mockReturnValue(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(harness.readCachedProcessCommandByPid(74735)).toBeNull();
|
||||||
|
expect(harness.readCachedProcessCommandByPid(74735)).toBeNull();
|
||||||
|
expect(directRead).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(26);
|
||||||
|
|
||||||
|
expect(harness.readCachedProcessCommandByPid(74735)).toBeNull();
|
||||||
|
expect(directRead).toHaveBeenCalledTimes(2);
|
||||||
|
} finally {
|
||||||
|
runtimeCommandCacheStatics.RUNTIME_PROCESS_COMMAND_MISS_CACHE_TTL_MS = originalMissTtl;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('does not let removed base member metadata hide an active suffixed member', async () => {
|
it('does not let removed base member metadata hide an active suffixed member', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue