feat: add telemetry identity and runtime status

This commit is contained in:
777genius 2026-05-17 20:26:34 +03:00
parent 445932e45b
commit 4ec745268b
24 changed files with 1405 additions and 90 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View file

@ -0,0 +1,12 @@
# Member Runtime Telemetry Reference
Design reference for participant-card runtime telemetry:
![Variant B reference](assets/team-member-runtime-telemetry-variant-b-reference.png)
Chosen direction: Variant B.
- Memory history renders as a subtle green filled micro-area at the bottom of each member row.
- CPU history renders as a thin blue line immediately above the memory band.
- The strip stays behind row content and uses low contrast so member names, model labels, task pills, and icons remain readable.
- Runtime history is owned by the main process and attached to `TeamAgentRuntimeSnapshot`, not accumulated in React components.

View file

@ -137,6 +137,7 @@ import {
SkillsMutationService,
SkillsWatcherService,
} from './services/extensions';
import { applyAgentTeamsIdentityEnv } from './services/identity/AgentTeamsIdentityStore';
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
import { HttpServer } from './services/infrastructure/HttpServer';
import { clearAutoResumeService } from './services/team/AutoResumeService';
@ -358,6 +359,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
reportProgress('runtime-environment', 'Preparing runtime environment...');
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
applyAgentTeamsIdentityEnv(bridgeEnv);
bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId;
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
const useHttpMcpBridge = isOpenCodeMcpHttpBridgeEnabled(bridgeEnv);

View file

@ -10,6 +10,11 @@
* loaded in standalone (non-Electron) mode without crashing.
*/
import {
type AgentTeamsIdentitySource,
ensureAgentTeamsClientIdentity,
getSentryAnonymousUserId,
} from '@main/services/identity/AgentTeamsIdentityStore';
import {
isValidDsn,
SENTRY_ENVIRONMENT,
@ -26,6 +31,18 @@ import {
// Defaults to `true` so early crash reports are NOT silently dropped;
// if the user later turns telemetry off, the flag flips to `false`.
let telemetryAllowed = true;
let telemetryIdentitySyncToken = 0;
export function getSafeSentryTelemetryTags(
identitySource: AgentTeamsIdentitySource
): Record<string, string> {
return {
platform: process.platform,
arch: process.arch,
app_version: SENTRY_RELEASE ?? 'unknown',
identity_source: identitySource,
};
}
/**
* Call once ConfigManager is initialised to sync the opt-in flag.
@ -33,16 +50,80 @@ let telemetryAllowed = true;
*/
export function syncTelemetryFlag(enabled: boolean): void {
telemetryAllowed = enabled;
void syncTelemetryIdentity();
}
export function filterSentryEventForTelemetry(event: unknown): unknown {
return telemetryAllowed ? event : null;
}
// ---------------------------------------------------------------------------
// Lazy Sentry import — safe in non-Electron environments
// ---------------------------------------------------------------------------
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let Sentry: any = null;
interface SentryMainApi {
init?: (options: SentryInitOptions) => void;
setUser?: (user: { id: string } | null) => void;
setTags?: (tags: Record<string, string>) => void;
addBreadcrumb?: (breadcrumb: {
category: string;
message: string;
data?: Record<string, unknown>;
level: 'info';
}) => void;
startSpan?: <T>(context: { name: string; op: string }, callback: () => T) => T;
}
interface SentryInitOptions {
dsn: string;
release: string | undefined;
environment: string;
tracesSampleRate: number;
sendDefaultPii: false;
beforeSend: (event: unknown) => unknown;
beforeSendTransaction: (event: unknown) => unknown;
}
let Sentry: SentryMainApi | null = null;
let initialized = false;
export function setMainSentryApiForTesting(sentryApi: SentryMainApi): void {
if (process.env.NODE_ENV !== 'test') return;
Sentry = sentryApi;
initialized = true;
}
function clearSentryUser(): void {
if (!initialized || !Sentry) return;
Sentry.setUser?.(null);
}
async function syncTelemetryIdentity(): Promise<void> {
const syncToken = ++telemetryIdentitySyncToken;
if (!initialized || !Sentry) {
return;
}
if (!telemetryAllowed) {
clearSentryUser();
return;
}
try {
const identity = await ensureAgentTeamsClientIdentity();
if (syncToken !== telemetryIdentitySyncToken || !telemetryAllowed) {
return;
}
Sentry.setUser?.({ id: getSentryAnonymousUserId(identity.clientId) });
Sentry.setTags?.(getSafeSentryTelemetryTags(identity.source));
} catch {
if (syncToken === telemetryIdentitySyncToken) {
clearSentryUser();
}
}
}
const dsn = process.env.SENTRY_DSN;
if (isValidDsn(dsn)) {
@ -51,18 +132,17 @@ if (isValidDsn(dsn)) {
// in all contexts. require() is synchronous and works in both Electron
// and Node.js — it simply throws in standalone mode where the electron
// module is not resolvable.
// eslint-disable-next-line @typescript-eslint/no-require-imports
Sentry = require('@sentry/electron/main');
Sentry.init({
// eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy optional Electron runtime dependency.
Sentry = require('@sentry/electron/main') as SentryMainApi;
Sentry.init?.({
dsn,
release: SENTRY_RELEASE,
environment: SENTRY_ENVIRONMENT,
tracesSampleRate: TRACES_SAMPLE_RATE,
sendDefaultPii: false,
beforeSend(event: unknown) {
return telemetryAllowed ? event : null;
},
beforeSend: filterSentryEventForTelemetry,
beforeSendTransaction: filterSentryEventForTelemetry,
});
initialized = true;
} catch {
@ -83,7 +163,7 @@ export function addMainBreadcrumb(
data?: Record<string, unknown>
): void {
if (!initialized) return;
Sentry.addBreadcrumb({ category, message, data, level: 'info' });
Sentry?.addBreadcrumb?.({ category, message, data, level: 'info' });
}
/**
@ -92,5 +172,6 @@ export function addMainBreadcrumb(
*/
export function startMainSpan<T>(name: string, op: string, fn: () => T): T {
if (!initialized) return fn();
if (!Sentry?.startSpan) return fn();
return Sentry.startSpan({ name, op }, fn);
}

View file

@ -0,0 +1,218 @@
import { atomicWriteAsync } from '@main/utils/atomicWrite';
import {
getAppDataPath,
getAutoDetectedClaudeBasePath,
getClaudeBasePath,
getHomeDir,
} from '@main/utils/pathDecoder';
import { createHash, randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
export const AGENT_TEAMS_IDENTITY_STORE_PATH_ENV = 'AGENT_TEAMS_IDENTITY_STORE_PATH';
export const AGENT_TEAMS_IDENTITY_SCHEMA_VERSION = 1;
const SENTRY_ANONYMOUS_USER_PREFIX = 'agent-teams-sentry-v1:';
const IDENTITY_DIR_MODE = 0o700;
const IDENTITY_FILE_MODE = 0o600;
type ParsedJson = null | boolean | number | string | ParsedJson[] | { [key: string]: ParsedJson };
export type AgentTeamsIdentitySource = 'app-data' | 'legacy-global-config' | 'created';
export interface AgentTeamsIdentityStoreV1 {
schemaVersion: typeof AGENT_TEAMS_IDENTITY_SCHEMA_VERSION;
clientId: string;
session?: Record<string, unknown>;
capabilities?: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
export interface AgentTeamsClientIdentity {
clientId: string;
source: AgentTeamsIdentitySource;
storePath: string;
}
interface LegacyAgentTeamsState {
clientId: string;
session?: Record<string, unknown>;
capabilities?: Record<string, unknown>;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function isValidAgentTeamsClientId(value: unknown): value is string {
return (
typeof value === 'string' &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function pickObjectField(
record: Record<string, unknown>,
key: string
): Record<string, unknown> | undefined {
const value = record[key];
return isRecord(value) ? value : undefined;
}
export function getAgentTeamsIdentityStorePath(): string {
return path.join(getAppDataPath(), 'identity', 'agent-teams-client.json');
}
export function applyAgentTeamsIdentityEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const existing = env[AGENT_TEAMS_IDENTITY_STORE_PATH_ENV];
if (!isNonEmptyString(existing)) {
env[AGENT_TEAMS_IDENTITY_STORE_PATH_ENV] = getAgentTeamsIdentityStorePath();
}
return env;
}
export function getSentryAnonymousUserId(clientId: string): string {
if (!isValidAgentTeamsClientId(clientId)) {
throw new Error('Invalid Agent Teams clientId');
}
return createHash('sha256').update(`${SENTRY_ANONYMOUS_USER_PREFIX}${clientId}`).digest('hex');
}
function getLegacyGlobalConfigPath(): string {
const claudeBasePath = getClaudeBasePath();
return claudeBasePath !== getAutoDetectedClaudeBasePath()
? path.join(claudeBasePath, '.claude.json')
: path.join(getHomeDir(), '.claude.json');
}
async function readJsonFile(filePath: string): Promise<ParsedJson | undefined> {
try {
const raw = await fs.promises.readFile(filePath, 'utf8');
return JSON.parse(raw) as ParsedJson;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return undefined;
}
return undefined;
}
}
async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.promises.stat(filePath);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code !== 'ENOENT';
}
}
function normalizeStoreRecord(value: unknown): AgentTeamsIdentityStoreV1 | null {
if (!isRecord(value)) {
return null;
}
if (value.schemaVersion !== AGENT_TEAMS_IDENTITY_SCHEMA_VERSION) {
return null;
}
if (!isValidAgentTeamsClientId(value.clientId)) {
return null;
}
const createdAt = isNonEmptyString(value.createdAt) ? value.createdAt : new Date().toISOString();
const updatedAt = isNonEmptyString(value.updatedAt) ? value.updatedAt : createdAt;
return {
schemaVersion: AGENT_TEAMS_IDENTITY_SCHEMA_VERSION,
clientId: value.clientId,
session: pickObjectField(value, 'session'),
capabilities: pickObjectField(value, 'capabilities'),
createdAt,
updatedAt,
};
}
function normalizeLegacyAgentTeams(value: unknown): LegacyAgentTeamsState | null {
if (!isRecord(value) || !isValidAgentTeamsClientId(value.clientId)) {
return null;
}
return {
clientId: value.clientId,
session: pickObjectField(value, 'session'),
capabilities: pickObjectField(value, 'capabilities'),
};
}
async function readLegacyAgentTeamsState(): Promise<LegacyAgentTeamsState | null> {
const legacyConfig = await readJsonFile(getLegacyGlobalConfigPath());
if (!isRecord(legacyConfig)) {
return null;
}
return normalizeLegacyAgentTeams(legacyConfig.agentTeams);
}
function buildStoreRecord(
source: LegacyAgentTeamsState | null,
options?: { existingCreatedAt?: string }
): AgentTeamsIdentityStoreV1 {
const now = new Date().toISOString();
return {
schemaVersion: AGENT_TEAMS_IDENTITY_SCHEMA_VERSION,
clientId: source?.clientId ?? randomUUID(),
session: source?.session,
capabilities: source?.capabilities,
createdAt: options?.existingCreatedAt ?? now,
updatedAt: now,
};
}
async function writeStoreRecord(
storePath: string,
record: AgentTeamsIdentityStoreV1
): Promise<void> {
const dir = path.dirname(storePath);
await fs.promises.mkdir(dir, { recursive: true, mode: IDENTITY_DIR_MODE });
await fs.promises.chmod(dir, IDENTITY_DIR_MODE).catch(() => undefined);
await atomicWriteAsync(storePath, `${JSON.stringify(record, null, 2)}\n`);
await fs.promises.chmod(storePath, IDENTITY_FILE_MODE).catch(() => undefined);
}
async function loadAppDataIdentity(storePath: string): Promise<AgentTeamsIdentityStoreV1 | null> {
return normalizeStoreRecord(await readJsonFile(storePath));
}
export async function ensureAgentTeamsClientIdentity(options?: {
storePath?: string;
}): Promise<AgentTeamsClientIdentity> {
const storePath = options?.storePath ?? getAgentTeamsIdentityStorePath();
const existing = await loadAppDataIdentity(storePath);
if (existing) {
return {
clientId: existing.clientId,
source: 'app-data',
storePath,
};
}
const legacy = !(await pathExists(storePath)) ? await readLegacyAgentTeamsState() : null;
const record = buildStoreRecord(legacy);
await writeStoreRecord(storePath, record);
return {
clientId: record.clientId,
source: legacy ? 'legacy-global-config' : 'created',
storePath,
};
}
export async function readAgentTeamsIdentityStore(options?: {
storePath?: string;
}): Promise<AgentTeamsIdentityStoreV1 | null> {
return loadAppDataIdentity(options?.storePath ?? getAgentTeamsIdentityStorePath());
}

View file

@ -1,3 +1,4 @@
import { applyAgentTeamsIdentityEnv } from '@main/services/identity/AgentTeamsIdentityStore';
import { buildEnrichedEnv } from '@main/utils/cliEnv';
import { getShellPreferredHome } from '@main/utils/shellEnv';
@ -43,6 +44,7 @@ export function buildRuntimeBaseEnv(options: BuildRuntimeBaseEnvOptions = {}): {
applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime);
Object.assign(env, options.env ?? {});
applyAgentTeamsIdentityEnv(env);
const policyAppliedEnv = applyOpenCodeAutoUpdatePolicy(env);
if (policyAppliedEnv.OPENCODE_DISABLE_AUTOUPDATE === undefined) {
delete env.OPENCODE_DISABLE_AUTOUPDATE;

View file

@ -1,3 +1,4 @@
import { applyAgentTeamsIdentityEnv } from '@main/services/identity/AgentTeamsIdentityStore';
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
import { getClaudeBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
@ -153,7 +154,7 @@ export class AgentTeamsMcpHttpServer {
const launchSpec = await resolveLaunchSpec();
const port = await allocatePort();
const args = buildHttpServerArgs(launchSpec, port);
const child = spawnProcess(launchSpec.command, args, {
const childEnv = applyAgentTeamsIdentityEnv({
...process.env,
AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(),
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
@ -161,6 +162,7 @@ export class AgentTeamsMcpHttpServer {
AGENT_TEAMS_MCP_HTTP_PORT: String(port),
AGENT_TEAMS_MCP_HTTP_ENDPOINT: MCP_HTTP_ENDPOINT,
});
const child = spawnProcess(launchSpec.command, args, childEnv);
const clearIfCurrent = (): void => {
if (this.child === child) {

View file

@ -53,7 +53,9 @@ interface ParsedJsonlEntry {
lineNumber: number;
}
function shouldWarnAboutMissingTaskLogs(input: ResolvedTaskChangeComputeInput): boolean {
function shouldWarnAboutUnavailableTaskChangeEvidence(
input: ResolvedTaskChangeComputeInput
): boolean {
const status = input.taskMeta?.status?.trim() || input.effectiveOptions.status?.trim();
const stateBucket = getTaskChangeStateBucket({
status,
@ -141,7 +143,7 @@ export class TaskChangeComputer {
});
if (intervalScoped) return intervalScoped;
return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath, includeDetails);
return this.fallbackSingleTaskScope(input, logRefs);
}
const files = await this.extractScopedChanges(logRefs, allScopes, projectPath, includeDetails);
@ -418,17 +420,17 @@ export class TaskChangeComputer {
}
private async fallbackSingleTaskScope(
teamName: string,
taskId: string,
logRefs: LogFileRef[],
projectPath?: string,
includeDetails = true
input: ResolvedTaskChangeComputeInput,
logRefs: LogFileRef[]
): Promise<TaskChangeSetV2> {
const { teamName, taskId, projectPath, includeDetails } = input;
const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath));
const allSnippets = this.sortSnippetsChronologically(
allParsed.flatMap((result) => result.snippets.map((record) => record.snippet))
);
const aggregatedFiles = this.aggregateByFile(allSnippets, projectPath, includeDetails);
const shouldWarn =
aggregatedFiles.length > 0 || shouldWarnAboutUnavailableTaskChangeEvidence(input);
return {
teamName,
@ -450,7 +452,9 @@ export class TaskChangeComputer {
filePaths: aggregatedFiles.map((file) => file.filePath),
confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' },
},
warnings: ['No task boundaries found - showing all changes from related sessions.'],
warnings: shouldWarn
? ['No task boundaries found - showing all changes from related sessions.']
: [],
};
}
@ -476,7 +480,9 @@ export class TaskChangeComputer {
filePaths: [],
confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' },
},
warnings: shouldWarnAboutMissingTaskLogs(input) ? [NO_LOG_FILES_FOUND_WARNING] : [],
warnings: shouldWarnAboutUnavailableTaskChangeEvidence(input)
? [NO_LOG_FILES_FOUND_WARNING]
: [],
};
}

View file

@ -544,6 +544,7 @@ import type {
TeamAgentRuntimeEntry,
TeamAgentRuntimeLivenessKind,
TeamAgentRuntimePidSource,
TeamAgentRuntimeResourceSample,
TeamAgentRuntimeSnapshot,
TeamChangeEvent,
TeamConfig,
@ -577,6 +578,11 @@ import type {
// 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 };
interface RuntimeProcessUsageStats {
rssBytes?: number;
cpuPercent?: number;
}
const logger = createLogger('Service:TeamProvisioning');
const PREFLIGHT_DEBUG_LOG_PATH = path.join(os.tmpdir(), 'claude-team-preflight-debug.log');
@ -5851,6 +5857,7 @@ export class TeamProvisioningService {
private static readonly SAME_TEAM_RUN_START_SKEW_MS = 1_000;
private static readonly SAME_TEAM_PERSIST_RETRY_MS = 2_000;
private static readonly AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS = 2_000;
private static readonly AGENT_RUNTIME_RESOURCE_HISTORY_LIMIT = 60;
private static readonly MEMBER_SPAWN_STATUS_SNAPSHOT_CACHE_TTL_MS = 500;
private static readonly LAUNCH_STATE_NOOP_REFRESH_MS = 15_000;
private static readonly RETAINED_PROVISIONING_PROGRESS_TTL_MS = 5 * 60_000;
@ -5926,6 +5933,10 @@ export class TeamProvisioningService {
string,
{ expiresAtMs: number; snapshot: TeamAgentRuntimeSnapshot }
>();
private readonly agentRuntimeResourceHistoryByTeam = new Map<
string,
Map<string, TeamAgentRuntimeResourceSample[]>
>();
private readonly agentRuntimeSnapshotInFlightByTeam = new Map<
string,
{
@ -15127,9 +15138,10 @@ export class TeamProvisioningService {
runtimePids.add(memberPid);
}
}
const rssBytesByPid = await this.readProcessRssBytesByPid([...runtimePids]);
const usageStatsByPid = await this.readProcessUsageStatsByPid([...runtimePids]);
const persistedRuntimeMembers = this.readPersistedRuntimeMembers(teamName);
const snapshotMembers: Record<string, TeamAgentRuntimeEntry> = {};
const activeResourceHistoryKeys = new Set<string>();
const getPersistedRuntimeMember = (
memberName: string
@ -15221,7 +15233,7 @@ export class TeamProvisioningService {
const isLead = isLeadMember({ name: memberName, agentType: member.agentType });
if (isLead) {
const pid = run?.child?.pid;
const rssBytes = pid ? rssBytesByPid.get(pid) : undefined;
const usageStats = pid ? usageStatsByPid.get(pid) : undefined;
const runtimeModel =
run?.request.model?.trim() ||
(run?.spawnContext
@ -15229,6 +15241,18 @@ export class TeamProvisioningService {
: undefined) ||
member.model?.trim() ||
undefined;
const resourceHistory = pid
? this.recordAgentRuntimeResourceSample({
teamName,
memberName,
timestamp: updatedAt,
cpuPercent: usageStats?.cpuPercent,
rssBytes: usageStats?.rssBytes,
pidSource: 'lead_process',
pid,
activeKeys: activeResourceHistoryKeys,
})
: undefined;
snapshotMembers[memberName] = {
memberName,
alive: Boolean(pid && !run?.processKilled && !run?.cancelRequested),
@ -15236,7 +15260,10 @@ export class TeamProvisioningService {
backendType: 'lead',
...(pid ? { pid } : {}),
...(runtimeModel ? { runtimeModel } : {}),
...(rssBytes != null ? { rssBytes } : {}),
...(usageStats?.rssBytes != null ? { rssBytes: usageStats.rssBytes } : {}),
...(usageStats?.cpuPercent != null ? { cpuPercent: usageStats.cpuPercent } : {}),
...(pid ? { pidSource: 'lead_process' as const } : {}),
...(resourceHistory && resourceHistory.length > 0 ? { resourceHistory } : {}),
updatedAt,
};
continue;
@ -15342,18 +15369,32 @@ export class TeamProvisioningService {
liveRuntimeMember?.livenessKind === 'runtime_process_candidate'
? 'info'
: liveRuntimeMember?.runtimeDiagnosticSeverity;
let rssBytes = rssPid ? rssBytesByPid.get(rssPid) : undefined;
if (rssBytes == null && isSharedOpenCodeHost && typeof rssPid === 'number' && rssPid > 0) {
let usageStats = rssPid ? usageStatsByPid.get(rssPid) : undefined;
if (!usageStats && isSharedOpenCodeHost && typeof rssPid === 'number' && rssPid > 0) {
try {
const refreshedStat = await pidusage(rssPid, RUNTIME_PIDUSAGE_OPTIONS);
if (Number.isFinite(refreshedStat.memory) && refreshedStat.memory >= 0) {
rssBytesByPid.set(rssPid, refreshedStat.memory);
rssBytes = refreshedStat.memory;
const refreshedUsageStats = this.normalizeRuntimeProcessUsageStats(refreshedStat);
if (refreshedUsageStats) {
usageStatsByPid.set(rssPid, refreshedUsageStats);
usageStats = refreshedUsageStats;
}
} catch {
// Shared OpenCode host can exit between discovery and the targeted RSS refresh.
}
}
const resourceHistory = rssPid
? this.recordAgentRuntimeResourceSample({
teamName,
memberName,
timestamp: updatedAt,
cpuPercent: usageStats?.cpuPercent,
rssBytes: usageStats?.rssBytes,
pidSource: liveRuntimeMember?.pidSource,
pid: rssPid,
runtimePid: liveRuntimeMember?.metricsPid,
activeKeys: activeResourceHistoryKeys,
})
: undefined;
snapshotMembers[memberName] = {
memberName,
@ -15367,7 +15408,9 @@ export class TeamProvisioningService {
...(displayPid ? { pid: displayPid } : {}),
...(runtimeModel ? { runtimeModel } : {}),
...(runtimeCwd ? { cwd: runtimeCwd } : {}),
...(typeof rssBytes === 'number' && rssBytes >= 0 ? { rssBytes } : {}),
...(usageStats?.rssBytes != null ? { rssBytes: usageStats.rssBytes } : {}),
...(usageStats?.cpuPercent != null ? { cpuPercent: usageStats.cpuPercent } : {}),
...(resourceHistory && resourceHistory.length > 0 ? { resourceHistory } : {}),
...(effectiveLivenessKind ? { livenessKind: effectiveLivenessKind } : {}),
...(liveRuntimeMember?.pidSource ? { pidSource: liveRuntimeMember.pidSource } : {}),
...(liveRuntimeMember?.processCommand
@ -15394,6 +15437,7 @@ export class TeamProvisioningService {
updatedAt,
};
}
this.pruneAgentRuntimeResourceHistory(teamName, activeResourceHistoryKeys);
const persistedLaunchIdentity = persistedTeamMeta?.launchIdentity;
const snapshotProviderId =
@ -25624,24 +25668,137 @@ export class TeamProvisioningService {
return metadataByMember;
}
private async readProcessRssBytesByPid(pids: readonly number[]): Promise<Map<number, number>> {
private buildAgentRuntimeResourceHistoryKey(params: {
memberName: string;
pid?: number;
runtimePid?: number;
pidSource?: TeamAgentRuntimePidSource;
}): string | null {
const memberName = params.memberName.trim();
const usagePid =
typeof params.pid === 'number' && Number.isFinite(params.pid) && params.pid > 0
? params.pid
: typeof params.runtimePid === 'number' &&
Number.isFinite(params.runtimePid) &&
params.runtimePid > 0
? params.runtimePid
: null;
if (!memberName || usagePid == null) {
return null;
}
return [memberName, usagePid, params.pidSource ?? 'unknown'].join('\0');
}
private recordAgentRuntimeResourceSample(params: {
teamName: string;
memberName: string;
timestamp: string;
cpuPercent?: number;
rssBytes?: number;
pidSource?: TeamAgentRuntimePidSource;
pid?: number;
runtimePid?: number;
activeKeys?: Set<string>;
}): TeamAgentRuntimeResourceSample[] | undefined {
const key = this.buildAgentRuntimeResourceHistoryKey(params);
if (!key) {
return undefined;
}
params.activeKeys?.add(key);
const cpuPercent =
typeof params.cpuPercent === 'number' &&
Number.isFinite(params.cpuPercent) &&
params.cpuPercent >= 0
? params.cpuPercent
: undefined;
const rssBytes =
typeof params.rssBytes === 'number' &&
Number.isFinite(params.rssBytes) &&
params.rssBytes >= 0
? params.rssBytes
: undefined;
let historyByKey = this.agentRuntimeResourceHistoryByTeam.get(params.teamName);
if (!historyByKey) {
historyByKey = new Map<string, TeamAgentRuntimeResourceSample[]>();
this.agentRuntimeResourceHistoryByTeam.set(params.teamName, historyByKey);
}
const existingHistory = historyByKey.get(key) ?? [];
if (cpuPercent == null && rssBytes == null) {
return existingHistory.length > 0
? existingHistory.map((sample) => ({ ...sample }))
: undefined;
}
const sample: TeamAgentRuntimeResourceSample = {
timestamp: params.timestamp,
...(cpuPercent != null ? { cpuPercent } : {}),
...(rssBytes != null ? { rssBytes } : {}),
...(params.pidSource ? { pidSource: params.pidSource } : {}),
...(params.pid ? { pid: params.pid } : {}),
...(params.runtimePid ? { runtimePid: params.runtimePid } : {}),
};
const nextHistory = [...existingHistory, sample].slice(
-TeamProvisioningService.AGENT_RUNTIME_RESOURCE_HISTORY_LIMIT
);
historyByKey.set(key, nextHistory);
return nextHistory.map((entry) => ({ ...entry }));
}
private pruneAgentRuntimeResourceHistory(
teamName: string,
activeKeys: ReadonlySet<string>
): void {
const historyByKey = this.agentRuntimeResourceHistoryByTeam.get(teamName);
if (!historyByKey) {
return;
}
for (const key of historyByKey.keys()) {
if (!activeKeys.has(key)) {
historyByKey.delete(key);
}
}
if (historyByKey.size === 0) {
this.agentRuntimeResourceHistoryByTeam.delete(teamName);
}
}
private normalizeRuntimeProcessUsageStats(
stat: { memory?: number; cpu?: number } | undefined
): RuntimeProcessUsageStats | undefined {
const rssBytes = stat?.memory;
const cpuPercent = stat?.cpu;
const normalized: RuntimeProcessUsageStats = {
...(typeof rssBytes === 'number' && Number.isFinite(rssBytes) && rssBytes >= 0
? { rssBytes }
: {}),
...(typeof cpuPercent === 'number' && Number.isFinite(cpuPercent) && cpuPercent >= 0
? { cpuPercent }
: {}),
};
return normalized.rssBytes != null || normalized.cpuPercent != null ? normalized : undefined;
}
private async readProcessUsageStatsByPid(
pids: readonly number[]
): Promise<Map<number, RuntimeProcessUsageStats>> {
const uniquePids = [...new Set(pids.filter((pid) => Number.isFinite(pid) && pid > 0))];
if (uniquePids.length === 0) {
return new Map();
}
const rssBytesByPid = new Map<number, number>();
const usageStatsByPid = new Map<number, RuntimeProcessUsageStats>();
const options = RUNTIME_PIDUSAGE_OPTIONS;
try {
const statsByPid = await pidusage(uniquePids, options);
for (const [rawPid, stat] of Object.entries(statsByPid)) {
const pid = Number.parseInt(rawPid, 10);
const rssBytes = stat?.memory;
if (Number.isFinite(pid) && pid > 0 && Number.isFinite(rssBytes) && rssBytes >= 0) {
rssBytesByPid.set(pid, rssBytes);
const usageStats = this.normalizeRuntimeProcessUsageStats(stat);
if (Number.isFinite(pid) && pid > 0 && usageStats) {
usageStatsByPid.set(pid, usageStats);
}
}
return rssBytesByPid;
return usageStatsByPid;
} catch (error) {
logger.debug(
`pidusage batch runtime snapshot failed; falling back to per-pid reads: ${
@ -25654,15 +25811,16 @@ export class TeamProvisioningService {
uniquePids.map(async (pid) => {
try {
const stat = await pidusage(pid, options);
if (Number.isFinite(stat.memory) && stat.memory >= 0) {
rssBytesByPid.set(pid, stat.memory);
const usageStats = this.normalizeRuntimeProcessUsageStats(stat);
if (usageStats) {
usageStatsByPid.set(pid, usageStats);
}
} catch {
// Process likely exited between discovery and sampling.
}
})
);
return rssBytesByPid;
return usageStatsByPid;
}
private async clearPersistedLaunchState(

View file

@ -8,6 +8,7 @@
* can find the tools they need and authenticate properly.
*/
import { applyAgentTeamsIdentityEnv } from '@main/services/identity/AgentTeamsIdentityStore';
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
import { getAutoDetectedClaudeBasePath, getClaudeBasePath } from '@main/utils/pathDecoder';
import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv';
@ -36,7 +37,7 @@ export function buildEnrichedEnv(binaryPath?: string | null): NodeJS.ProcessEnv
const configDir = getClaudeBasePath();
const isCustomConfigDir = configDir !== getAutoDetectedClaudeBasePath();
return {
return applyAgentTeamsIdentityEnv({
...process.env,
...(shellEnv ?? {}),
HOME: home,
@ -49,5 +50,5 @@ export function buildEnrichedEnv(binaryPath?: string | null): NodeJS.ProcessEnv
LOGNAME: shellEnv?.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user,
}
: {}),
};
});
}

View file

@ -42,6 +42,7 @@ import type {
MemberSpawnStatusEntry,
ResolvedTeamMember,
TeamAgentRuntimeEntry,
TeamAgentRuntimeResourceSample,
TeamTaskWithKanban,
} from '@shared/types';
@ -114,6 +115,186 @@ function getLaunchFailureLinkLabel(url: string): string {
return url;
}
const RUNTIME_TELEMETRY_SAMPLE_LIMIT = 48;
const RUNTIME_TELEMETRY_WIDTH = 100;
const RUNTIME_TELEMETRY_HEIGHT = 18;
const RUNTIME_TELEMETRY_BASELINE_Y = 16.5;
interface TelemetryPoint {
x: number;
y: number;
}
interface RuntimeTelemetryPaths {
memoryAreaPath?: string;
memoryLinePath?: string;
cpuLinePath?: string;
}
function isFiniteNonNegative(value: number | undefined): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
}
function formatTelemetryCoordinate(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(2);
}
function buildLinePath(points: readonly TelemetryPoint[]): string | undefined {
if (points.length < 2) {
return undefined;
}
return points
.map((point, index) => {
const command = index === 0 ? 'M' : 'L';
return `${command}${formatTelemetryCoordinate(point.x)} ${formatTelemetryCoordinate(point.y)}`;
})
.join(' ');
}
function buildAreaPath(points: readonly TelemetryPoint[]): string | undefined {
if (points.length < 2) {
return undefined;
}
const first = points[0];
const last = points[points.length - 1];
return [
`M${formatTelemetryCoordinate(first.x)} ${formatTelemetryCoordinate(RUNTIME_TELEMETRY_BASELINE_Y)}`,
`L${formatTelemetryCoordinate(first.x)} ${formatTelemetryCoordinate(first.y)}`,
...points
.slice(1)
.map(
(point) => `L${formatTelemetryCoordinate(point.x)} ${formatTelemetryCoordinate(point.y)}`
),
`L${formatTelemetryCoordinate(last.x)} ${formatTelemetryCoordinate(RUNTIME_TELEMETRY_BASELINE_Y)}`,
'Z',
].join(' ');
}
function buildTelemetryPoints(
samples: readonly TeamAgentRuntimeResourceSample[],
getValue: (sample: TeamAgentRuntimeResourceSample) => number | undefined,
getY: (value: number, values: readonly number[]) => number
): TelemetryPoint[] {
const values = samples.map(getValue).filter(isFiniteNonNegative);
if (values.length < 2 || samples.length < 2) {
return [];
}
return samples.flatMap((sample, index) => {
const value = getValue(sample);
if (!isFiniteNonNegative(value)) {
return [];
}
return [
{
x: (index / (samples.length - 1)) * RUNTIME_TELEMETRY_WIDTH,
y: getY(value, values),
},
];
});
}
function buildRuntimeTelemetryPaths(
history: readonly TeamAgentRuntimeResourceSample[] | undefined
): RuntimeTelemetryPaths | undefined {
const samples = (history ?? []).slice(-RUNTIME_TELEMETRY_SAMPLE_LIMIT);
if (samples.length < 2) {
return undefined;
}
const memoryPoints = buildTelemetryPoints(
samples,
(sample) => sample.rssBytes,
(value, values) => {
const min = Math.min(...values);
const max = Math.max(...values);
const ratio = max > min ? (value - min) / (max - min) : 0.32;
return 15.25 - ratio * 4.4;
}
);
const cpuPoints = buildTelemetryPoints(
samples,
(sample) => sample.cpuPercent,
(value, values) => {
const max = Math.max(10, ...values);
const ratio = Math.min(1, value / max);
return 8.3 - ratio * 4.6;
}
);
const memoryAreaPath = buildAreaPath(memoryPoints);
const memoryLinePath = buildLinePath(memoryPoints);
const cpuLinePath = buildLinePath(cpuPoints);
if (!memoryAreaPath && !cpuLinePath) {
return undefined;
}
return {
memoryAreaPath,
memoryLinePath,
cpuLinePath,
};
}
const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({
runtimeEntry,
}: {
runtimeEntry?: TeamAgentRuntimeEntry;
}): React.JSX.Element | null {
const paths = useMemo(
() => buildRuntimeTelemetryPaths(runtimeEntry?.resourceHistory),
[runtimeEntry?.resourceHistory]
);
if (!paths) {
return null;
}
return (
<div
aria-hidden="true"
data-testid="member-runtime-telemetry-strip"
className="pointer-events-none absolute inset-x-0 bottom-0 z-0 h-5 overflow-hidden rounded-b"
>
<svg
className="size-full"
viewBox={`0 0 ${RUNTIME_TELEMETRY_WIDTH} ${RUNTIME_TELEMETRY_HEIGHT}`}
preserveAspectRatio="none"
>
{paths.memoryAreaPath ? (
<path d={paths.memoryAreaPath} fill="#22c55e" opacity="0.22" />
) : null}
{paths.memoryLinePath ? (
<path
d={paths.memoryLinePath}
fill="none"
stroke="#4ade80"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="0.55"
opacity="0.68"
/>
) : null}
{paths.cpuLinePath ? (
<path
d={paths.cpuLinePath}
fill="none"
stroke="#3b82f6"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="0.62"
opacity="0.78"
/>
) : null}
</svg>
<div
className="absolute inset-x-0 bottom-0 h-2"
style={{
background:
'linear-gradient(to top, color-mix(in srgb, var(--color-surface) 35%, transparent), transparent)',
}}
/>
</div>
);
});
export const MemberCard = memo(function MemberCard({
member,
memberColor,
@ -374,7 +555,10 @@ export const MemberCard = memo(function MemberCard({
)}
>
<div
className={cn('group relative cursor-pointer rounded py-1.5', rowSurfaceBleedClass)}
className={cn(
'group relative cursor-pointer overflow-hidden rounded py-1.5',
rowSurfaceBleedClass
)}
style={undefined}
title={activityTitle}
role="button"
@ -387,8 +571,9 @@ export const MemberCard = memo(function MemberCard({
}
}}
>
<div className="pointer-events-none absolute inset-0 rounded transition-colors group-hover:bg-white/5" />
<div className="flex items-center gap-2.5">
{!isRemoved ? <MemberRuntimeTelemetryStrip runtimeEntry={runtimeEntry} /> : null}
<div className="pointer-events-none absolute inset-0 z-10 rounded transition-colors group-hover:bg-white/5" />
<div className="relative z-20 flex items-center gap-2.5">
<div className="relative shrink-0">
<div
className="rounded-full border-2 p-px"

View file

@ -275,6 +275,8 @@ function areMemberRuntimeEntriesEquivalent(
const rightEntry = right.get(key);
const leftDiagnostics = leftEntry.diagnostics ?? [];
const rightDiagnostics = rightEntry?.diagnostics ?? [];
const leftResourceHistory = leftEntry.resourceHistory ?? [];
const rightResourceHistory = rightEntry?.resourceHistory ?? [];
if (
leftEntry.memberName !== rightEntry?.memberName ||
leftEntry.alive !== rightEntry?.alive ||
@ -287,6 +289,7 @@ function areMemberRuntimeEntriesEquivalent(
leftEntry.pid !== rightEntry?.pid ||
leftEntry.runtimeModel !== rightEntry?.runtimeModel ||
leftEntry.rssBytes !== rightEntry?.rssBytes ||
leftEntry.cpuPercent !== rightEntry?.cpuPercent ||
leftEntry.livenessKind !== rightEntry?.livenessKind ||
leftEntry.pidSource !== rightEntry?.pidSource ||
leftEntry.processCommand !== rightEntry?.processCommand ||
@ -300,7 +303,19 @@ function areMemberRuntimeEntriesEquivalent(
leftEntry.runtimeLastSeenAt !== rightEntry?.runtimeLastSeenAt ||
leftEntry.historicalBootstrapConfirmed !== rightEntry?.historicalBootstrapConfirmed ||
leftDiagnostics.length !== rightDiagnostics.length ||
!leftDiagnostics.every((value, index) => value === rightDiagnostics[index])
!leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) ||
leftResourceHistory.length !== rightResourceHistory.length ||
!leftResourceHistory.every((value, index) => {
const other = rightResourceHistory[index];
return (
value.timestamp === other?.timestamp &&
value.cpuPercent === other?.cpuPercent &&
value.rssBytes === other?.rssBytes &&
value.pidSource === other?.pidSource &&
value.pid === other?.pid &&
value.runtimePid === other?.runtimePid
);
})
) {
return false;
}

View file

@ -31,6 +31,9 @@ let initialized = false;
*/
export function syncRendererTelemetry(enabled: boolean): void {
telemetryAllowed = enabled;
if (!enabled && initialized && typeof SentryElectron.setUser === 'function') {
SentryElectron.setUser(null);
}
}
export function initSentryRenderer(): void {
@ -49,6 +52,8 @@ export function initSentryRenderer(): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- cross-version @sentry/core type mismatch
const beforeSend = (event: any): any => (telemetryAllowed ? event : null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- cross-version @sentry/core type mismatch
const beforeSendTransaction = (event: any): any => (telemetryAllowed ? event : null);
if (window.electronAPI) {
// Electron renderer — uses IPC transport to main process.
@ -57,6 +62,7 @@ export function initSentryRenderer(): void {
SentryElectron.init({
...baseOptions,
beforeSend,
beforeSendTransaction,
integrations: [SentryElectron.browserTracingIntegration()],
});
} else {
@ -64,6 +70,7 @@ export function initSentryRenderer(): void {
reactInit({
...baseOptions,
beforeSend,
beforeSendTransaction,
integrations: [reactBrowserTracing()],
});
}

View file

@ -986,6 +986,8 @@ function areTeamAgentRuntimeEntriesEqual(
if (!left || !right) return left === right;
const leftDiagnostics = left.diagnostics ?? [];
const rightDiagnostics = right.diagnostics ?? [];
const leftResourceHistory = left.resourceHistory ?? [];
const rightResourceHistory = right.resourceHistory ?? [];
return (
left.memberName === right.memberName &&
left.alive === right.alive &&
@ -998,6 +1000,7 @@ function areTeamAgentRuntimeEntriesEqual(
left.pid === right.pid &&
left.runtimeModel === right.runtimeModel &&
left.rssBytes === right.rssBytes &&
left.cpuPercent === right.cpuPercent &&
left.livenessKind === right.livenessKind &&
left.pidSource === right.pidSource &&
left.processCommand === right.processCommand &&
@ -1011,7 +1014,19 @@ function areTeamAgentRuntimeEntriesEqual(
left.runtimeLastSeenAt === right.runtimeLastSeenAt &&
left.historicalBootstrapConfirmed === right.historicalBootstrapConfirmed &&
leftDiagnostics.length === rightDiagnostics.length &&
leftDiagnostics.every((value, index) => value === rightDiagnostics[index])
leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) &&
leftResourceHistory.length === rightResourceHistory.length &&
leftResourceHistory.every((value, index) => {
const other = rightResourceHistory[index];
return (
value.timestamp === other?.timestamp &&
value.cpuPercent === other?.cpuPercent &&
value.rssBytes === other?.rssBytes &&
value.pidSource === other?.pidSource &&
value.pid === other?.pid &&
value.runtimePid === other?.runtimePid
);
})
);
}

View file

@ -1199,6 +1199,15 @@ export type TeamAgentRuntimePidSource =
export type TeamAgentRuntimeDiagnosticSeverity = 'info' | 'warning' | 'error';
export interface TeamAgentRuntimeResourceSample {
timestamp: string;
cpuPercent?: number;
rssBytes?: number;
pidSource?: TeamAgentRuntimePidSource;
pid?: number;
runtimePid?: number;
}
export interface TeamAgentRuntimeEntry {
memberName: string;
alive: boolean;
@ -1213,6 +1222,8 @@ export interface TeamAgentRuntimeEntry {
/** Runtime working directory, when known. */
cwd?: string;
rssBytes?: number;
cpuPercent?: number;
resourceHistory?: TeamAgentRuntimeResourceSample[];
livenessKind?: TeamAgentRuntimeLivenessKind;
pidSource?: TeamAgentRuntimePidSource;
processCommand?: string;

42
test/main/sentry.test.ts Normal file
View file

@ -0,0 +1,42 @@
import { vi } from 'vitest';
describe('main Sentry telemetry gate', () => {
let previousDsn: string | undefined;
beforeEach(() => {
previousDsn = process.env.SENTRY_DSN;
process.env.SENTRY_DSN = 'https://public@example.com/1';
vi.resetModules();
});
afterEach(() => {
if (previousDsn === undefined) {
delete process.env.SENTRY_DSN;
} else {
process.env.SENTRY_DSN = previousDsn;
}
vi.resetModules();
});
it('clears user scope and drops events when telemetry is disabled', async () => {
const sentry = await import('@main/sentry');
const sentryApi = {
setUser: vi.fn(),
setTags: vi.fn(),
};
sentry.setMainSentryApiForTesting(sentryApi);
sentry.syncTelemetryFlag(false);
expect(sentryApi.setUser).toHaveBeenCalledWith(null);
expect(sentry.filterSentryEventForTelemetry({ ok: true })).toBeNull();
});
it('only exposes safe low-cardinality telemetry tags', async () => {
const { getSafeSentryTelemetryTags } = await import('@main/sentry');
expect(
Object.keys(getSafeSentryTelemetryTags('app-data')).sort((a, b) => a.localeCompare(b))
).toEqual(['app_version', 'arch', 'identity_source', 'platform']);
});
});

View file

@ -0,0 +1,130 @@
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import {
AGENT_TEAMS_IDENTITY_STORE_PATH_ENV,
applyAgentTeamsIdentityEnv,
ensureAgentTeamsClientIdentity,
getAgentTeamsIdentityStorePath,
getSentryAnonymousUserId,
readAgentTeamsIdentityStore,
} from '@main/services/identity/AgentTeamsIdentityStore';
import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
const LEGACY_CLIENT_ID = '22222222-2222-4222-8222-222222222222';
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
describe('AgentTeamsIdentityStore', () => {
let tempRoot: string;
let tempHome: string;
let tempAppDataBase: string;
let previousHome: string | undefined;
beforeEach(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-teams-identity-'));
tempHome = path.join(tempRoot, 'home');
tempAppDataBase = path.join(tempRoot, 'app-user-data');
await fs.mkdir(tempHome, { recursive: true });
previousHome = process.env.HOME;
process.env.HOME = tempHome;
setClaudeBasePathOverride(null);
setAppDataBasePath(tempAppDataBase);
});
afterEach(async () => {
setClaudeBasePathOverride(null);
setAppDataBasePath(null);
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.rm(tempRoot, { recursive: true, force: true });
});
it('creates and reuses a stable app-data UUID', async () => {
const first = await ensureAgentTeamsClientIdentity();
const second = await ensureAgentTeamsClientIdentity();
const persisted = await readAgentTeamsIdentityStore();
expect(first.clientId).toMatch(UUID_PATTERN);
expect(second.clientId).toBe(first.clientId);
expect(first.source).toBe('created');
expect(second.source).toBe('app-data');
expect(persisted?.schemaVersion).toBe(1);
expect(persisted?.clientId).toBe(first.clientId);
});
it('falls back safely when app-data JSON or UUID is invalid', async () => {
const storePath = getAgentTeamsIdentityStorePath();
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(storePath, '{not-json', 'utf8');
const fromInvalidJson = await ensureAgentTeamsClientIdentity();
expect(fromInvalidJson.clientId).toMatch(UUID_PATTERN);
await fs.writeFile(
storePath,
JSON.stringify({
schemaVersion: 1,
clientId: 'not-a-uuid',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
'utf8'
);
const fromInvalidUuid = await ensureAgentTeamsClientIdentity();
expect(fromInvalidUuid.clientId).toMatch(UUID_PATTERN);
expect(fromInvalidUuid.clientId).not.toBe('not-a-uuid');
});
it('soft-migrates legacy ~/.claude.json agentTeams into app data', async () => {
await fs.writeFile(
path.join(tempHome, '.claude.json'),
JSON.stringify({
agentTeams: {
clientId: LEGACY_CLIENT_ID,
session: {
accessToken: 'legacy-access',
refreshToken: 'legacy-refresh',
},
capabilities: {
token: 'legacy-capabilities',
},
},
}),
'utf8'
);
const identity = await ensureAgentTeamsClientIdentity();
const persisted = await readAgentTeamsIdentityStore();
const legacy = JSON.parse(await fs.readFile(path.join(tempHome, '.claude.json'), 'utf8')) as {
agentTeams?: { clientId?: string };
};
expect(identity).toMatchObject({
clientId: LEGACY_CLIENT_ID,
source: 'legacy-global-config',
});
expect(persisted?.clientId).toBe(LEGACY_CLIENT_ID);
expect(legacy.agentTeams?.clientId).toBe(LEGACY_CLIENT_ID);
});
it('builds deterministic Sentry-safe anonymous user ids', () => {
const hashed = getSentryAnonymousUserId(LEGACY_CLIENT_ID);
expect(hashed).toBe(getSentryAnonymousUserId(LEGACY_CLIENT_ID));
expect(hashed).not.toBe(LEGACY_CLIENT_ID);
expect(hashed).toMatch(/^[a-f0-9]{64}$/);
});
it('sets the orchestrator identity store env path', () => {
const env: NodeJS.ProcessEnv = {};
applyAgentTeamsIdentityEnv(env);
expect(env[AGENT_TEAMS_IDENTITY_STORE_PATH_ENV]).toBe(getAgentTeamsIdentityStorePath());
});
});

View file

@ -1502,6 +1502,52 @@ describe('ChangeExtractorService', () => {
expect(deleteEntry).toHaveBeenCalledWith(TEAM_NAME, TASK_ID);
});
it('clears stale presence for pending related logs that have no task boundaries or file edits yet', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir, {
status: 'pending',
workIntervals: [],
});
const logPath = path.join(tmpDir, 'lead-pending.jsonl');
await writeJsonl(logPath, [
{
timestamp: '2026-03-01T10:00:00.000Z',
type: 'user',
message: {
role: 'user',
content: `Task ${TASK_ID} was created and is waiting to start.`,
},
},
]);
const upsertEntry = vi.fn(() => Promise.resolve(undefined));
const deleteEntry = vi.fn(() => Promise.resolve(undefined));
const ensureTracking = vi.fn(() =>
Promise.resolve({
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'log-generation',
})
);
const { service } = createService({
logPaths: [logPath],
taskChangePresenceRepository: { upsertEntry, deleteEntry },
teamLogSourceTracker: { ensureTracking },
});
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
...SUMMARY_OPTIONS,
status: 'completed',
stateBucket: 'completed',
});
expect(result.files).toHaveLength(0);
expect(result.warnings).toEqual([]);
expect(upsertEntry).not.toHaveBeenCalled();
expect(deleteEntry).toHaveBeenCalledWith(TEAM_NAME, TASK_ID);
});
it('passes task metadata status to task diff workers when request status is stale', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);

View file

@ -5,6 +5,28 @@ import { afterEach, describe, expect, it } from 'vitest';
import * as fs from 'fs/promises';
import { TaskChangeComputer } from '../../../../src/main/services/team/TaskChangeComputer';
import type { TaskChangeTaskMeta } from '../../../../src/main/services/team/taskChangeWorkerTypes';
const NO_TASK_BOUNDARIES_WARNING =
'No task boundaries found - showing all changes from related sessions.';
const noBoundaryTerminalCases: Array<{
name: string;
taskMeta: TaskChangeTaskMeta;
}> = [
{
name: 'completed',
taskMeta: { status: 'completed', reviewState: 'none' },
},
{
name: 'review',
taskMeta: { status: 'completed', reviewState: 'review' },
},
{
name: 'approved',
taskMeta: { status: 'completed', reviewState: 'approved' },
},
];
async function writeJsonl(filePath: string, entries: object[]): Promise<void> {
await fs.writeFile(
@ -99,6 +121,37 @@ function createNoLogTaskChangeComputer(): TaskChangeComputer {
return new TaskChangeComputer(logsFinder as never, boundaryParser as never);
}
function createNoBoundaryTaskChangeComputer(logPath: string): TaskChangeComputer {
const logsFinder = {
findLogFileRefsForTask: () => Promise.resolve([{ filePath: logPath, memberName: 'team-lead' }]),
};
const boundaryParser = {
parseBoundaries: () =>
Promise.resolve({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
}),
};
return new TaskChangeComputer(logsFinder as never, boundaryParser as never);
}
async function writeNoBoundaryTaskMentionLog(tmpDir: string, content: string): Promise<string> {
const logPath = path.join(tmpDir, 'lead.jsonl');
await writeJsonl(logPath, [
{
timestamp: '2026-03-01T10:00:00.000Z',
type: 'user',
message: {
role: 'user',
content,
},
},
]);
return logPath;
}
describe('TaskChangeComputer', () => {
let tmpDir: string | null = null;
@ -149,6 +202,108 @@ describe('TaskChangeComputer', () => {
expect(result.warnings).toEqual([]);
});
it('keeps pending tasks quiet when related logs exist but no file edits were captured yet', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-'));
const logPath = await writeNoBoundaryTaskMentionLog(
tmpDir,
'Task task-1 was created and is waiting to start.'
);
const computer = createNoBoundaryTaskChangeComputer(logPath);
const result = await computer.computeTaskChanges({
teamName: 'team-a',
taskId: 'task-1',
taskMeta: {
status: 'pending',
reviewState: 'none',
},
effectiveOptions: { status: 'completed' },
projectPath: '/repo',
includeDetails: false,
});
expect(result.files).toEqual([]);
expect(result.confidence).toBe('fallback');
expect(result.warnings).toEqual([]);
});
it('keeps in-progress related logs quiet when no boundaries or file edits were captured yet', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-'));
const logPath = await writeNoBoundaryTaskMentionLog(
tmpDir,
'Task task-1 is in progress and has not edited files yet.'
);
const computer = createNoBoundaryTaskChangeComputer(logPath);
const result = await computer.computeTaskChanges({
teamName: 'team-a',
taskId: 'task-1',
taskMeta: {
status: 'in_progress',
reviewState: 'none',
},
effectiveOptions: { status: 'completed' },
projectPath: '/repo',
includeDetails: false,
});
expect(result.files).toEqual([]);
expect(result.confidence).toBe('fallback');
expect(result.warnings).toEqual([]);
});
it('keeps reopened needs-fix related logs quiet when no boundaries or file edits were captured', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-'));
const logPath = await writeNoBoundaryTaskMentionLog(
tmpDir,
'Task task-1 was reopened for fixes and is waiting for edits.'
);
const computer = createNoBoundaryTaskChangeComputer(logPath);
const result = await computer.computeTaskChanges({
teamName: 'team-a',
taskId: 'task-1',
taskMeta: {
status: 'completed',
reviewState: 'needsFix',
},
effectiveOptions: { status: 'completed' },
projectPath: '/repo',
includeDetails: false,
});
expect(result.files).toEqual([]);
expect(result.confidence).toBe('fallback');
expect(result.warnings).toEqual([]);
});
it.each(noBoundaryTerminalCases)(
'warns when $name related logs have no task boundaries or file edits',
async ({ taskMeta }) => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-'));
const logPath = await writeNoBoundaryTaskMentionLog(
tmpDir,
'Task task-1 completed but no edit data was captured.'
);
const computer = createNoBoundaryTaskChangeComputer(logPath);
const result = await computer.computeTaskChanges({
teamName: 'team-a',
taskId: 'task-1',
taskMeta,
effectiveOptions: { status: 'in_progress' },
projectPath: '/repo',
includeDetails: false,
});
expect(result.files).toEqual([]);
expect(result.confidence).toBe('fallback');
expect(result.warnings).toEqual([NO_TASK_BOUNDARIES_WARNING]);
}
);
it('warns when completed tasks have no logs even when request status is stale', async () => {
const computer = createNoLogTaskChangeComputer();
@ -222,7 +377,9 @@ describe('TaskChangeComputer', () => {
]);
expect(first.files.map((file) => file.relativePath)).toEqual(['src/a.ts']);
expect(first.warnings).toEqual([NO_TASK_BOUNDARIES_WARNING]);
expect(second.files).toEqual(first.files);
expect(second.warnings).toEqual([NO_TASK_BOUNDARIES_WARNING]);
await new Promise((resolve) => setTimeout(resolve, 20));
await writeJsonl(logPath, [
@ -236,6 +393,7 @@ describe('TaskChangeComputer', () => {
.map((file) => file.relativePath)
.sort((left, right) => left.localeCompare(right))
).toEqual(['src/a.ts', 'src/b.ts']);
expect(afterChange.warnings).toEqual([NO_TASK_BOUNDARIES_WARNING]);
});
it('does not pull unrelated log changes into a precise task scope with no file edits', async () => {

View file

@ -66,6 +66,19 @@ const WORKSPACE_TRUST_TEST_ENV_NAMES = [
] as const;
type WorkspaceTrustTestEnvName = (typeof WORKSPACE_TRUST_TEST_ENV_NAMES)[number];
type RuntimeUsageStatsForTest = { rssBytes: number; cpuPercent?: number };
function createRuntimeUsageStatsMap(
entries: readonly (readonly [number, number])[]
): Map<number, RuntimeUsageStatsForTest> {
return new Map(entries.map(([pid, rssBytes]) => [pid, { rssBytes }]));
}
function createRuntimeUsageStatsByPid(
pids: readonly number[]
): Map<number, RuntimeUsageStatsForTest> {
return createRuntimeUsageStatsMap(pids.map((pid) => [pid, pid * 1_000] as const));
}
describe('Team agent launch matrix safe e2e', () => {
let tempDir: string;
@ -4030,8 +4043,8 @@ describe('Team agent launch matrix safe e2e', () => {
},
],
]);
(svc as any).readProcessRssBytesByPid = async () =>
new Map([[sharedHostPid, 183.9 * 1024 * 1024]]);
(svc as any).readProcessUsageStatsByPid = async () =>
createRuntimeUsageStatsMap([[sharedHostPid, 183.9 * 1024 * 1024]]);
await waitForCondition(async () => {
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
@ -4136,7 +4149,8 @@ describe('Team agent launch matrix safe e2e', () => {
},
],
]);
(svc as any).readProcessRssBytesByPid = async () => new Map([[sharedHostPid, sharedRssBytes]]);
(svc as any).readProcessUsageStatsByPid = async () =>
createRuntimeUsageStatsMap([[sharedHostPid, sharedRssBytes]]);
const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
@ -4237,7 +4251,8 @@ describe('Team agent launch matrix safe e2e', () => {
},
],
]);
(svc as any).readProcessRssBytesByPid = async () => new Map([[sharedHostPid, sharedRssBytes]]);
(svc as any).readProcessUsageStatsByPid = async () =>
createRuntimeUsageStatsMap([[sharedHostPid, sharedRssBytes]]);
const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
@ -4358,7 +4373,8 @@ describe('Team agent launch matrix safe e2e', () => {
},
],
]);
(svc as any).readProcessRssBytesByPid = async () => new Map([[sharedHostPid, sharedRssBytes]]);
(svc as any).readProcessUsageStatsByPid = async () =>
createRuntimeUsageStatsMap([[sharedHostPid, sharedRssBytes]]);
const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
@ -4441,8 +4457,8 @@ describe('Team agent launch matrix safe e2e', () => {
},
],
]);
(restartedService as any).readProcessRssBytesByPid = async () =>
new Map([[sharedHostPid, 188.4 * 1024 * 1024]]);
(restartedService as any).readProcessUsageStatsByPid = async () =>
createRuntimeUsageStatsMap([[sharedHostPid, 188.4 * 1024 * 1024]]);
const runtimeSnapshot = await restartedService.getTeamAgentRuntimeSnapshot(teamName);
@ -5200,7 +5216,8 @@ describe('Team agent launch matrix safe e2e', () => {
},
],
]);
(svc as any).readProcessRssBytesByPid = async () => new Map([[sharedHostPid, sharedRssBytes]]);
(svc as any).readProcessUsageStatsByPid = async () =>
createRuntimeUsageStatsMap([[sharedHostPid, sharedRssBytes]]);
const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
@ -13596,8 +13613,8 @@ describe('Team agent launch matrix safe e2e', () => {
['alice', { alive: true, pid: 64102, model: 'haiku-stale' }],
['bob', { alive: true, pid: 64103, model: 'sonnet-stale' }],
]);
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
new Map(pids.map((pid) => [pid, pid * 1_000]));
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
createRuntimeUsageStatsByPid(pids);
const staleSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
expect(staleSnapshot).toMatchObject({
@ -13645,8 +13662,8 @@ describe('Team agent launch matrix safe e2e', () => {
['alice', { alive: true, pid: 64502, model: 'haiku-before-stop' }],
['bob', { alive: true, pid: 64503, model: 'sonnet-before-stop' }],
]);
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
new Map(pids.map((pid) => [pid, pid * 1_000]));
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
createRuntimeUsageStatsByPid(pids);
const beforeStop = await svc.getTeamAgentRuntimeSnapshot(teamName);
expect(beforeStop).toMatchObject({
@ -13772,8 +13789,8 @@ describe('Team agent launch matrix safe e2e', () => {
},
],
]);
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
new Map(pids.map((pid) => [pid, pid * 1_000]));
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
createRuntimeUsageStatsByPid(pids);
const afterSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
expect(afterSwitch).toMatchObject({
@ -13822,8 +13839,8 @@ describe('Team agent launch matrix safe e2e', () => {
},
],
]);
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
new Map(pids.map((pid) => [pid, pid * 1_000]));
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
createRuntimeUsageStatsByPid(pids);
const afterSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
expect(afterSwitch).toMatchObject({
@ -13863,8 +13880,8 @@ describe('Team agent launch matrix safe e2e', () => {
},
],
]);
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
new Map(pids.map((pid) => [pid, pid * 1_000]));
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
createRuntimeUsageStatsByPid(pids);
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
expect(snapshot.members.alice).toMatchObject({
@ -13901,8 +13918,8 @@ describe('Team agent launch matrix safe e2e', () => {
},
],
]);
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
new Map(pids.map((pid) => [pid, pid * 1_000]));
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
createRuntimeUsageStatsByPid(pids);
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
expect(snapshot.members.alice).toMatchObject({
@ -13937,8 +13954,8 @@ describe('Team agent launch matrix safe e2e', () => {
},
],
]);
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
new Map(pids.map((pid) => [pid, pid * 1_000]));
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
createRuntimeUsageStatsByPid(pids);
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
expect(snapshot.members.bob).toMatchObject({
@ -13973,8 +13990,8 @@ describe('Team agent launch matrix safe e2e', () => {
},
],
]);
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
new Map(pids.map((pid) => [pid, pid * 1_000]));
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
createRuntimeUsageStatsByPid(pids);
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
expect(snapshot.members.bob).toMatchObject({
@ -14018,8 +14035,8 @@ describe('Team agent launch matrix safe e2e', () => {
},
],
]);
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
new Map(pids.map((pid) => [pid, pid * 1_000]));
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
createRuntimeUsageStatsByPid(pids);
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
expect(snapshot.members.reviewer).toMatchObject({
@ -14063,8 +14080,8 @@ describe('Team agent launch matrix safe e2e', () => {
},
],
]);
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
new Map(pids.map((pid) => [pid, pid * 1_000]));
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
createRuntimeUsageStatsByPid(pids);
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
expect(snapshot.members.reviewer).toMatchObject({
@ -14199,8 +14216,8 @@ describe('Team agent launch matrix safe e2e', () => {
},
],
]);
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
new Map(pids.map((pid) => [pid, pid * 1_000]));
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
createRuntimeUsageStatsByPid(pids);
const afterSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
expect(afterSwitch).toMatchObject({
@ -14249,8 +14266,8 @@ describe('Team agent launch matrix safe e2e', () => {
},
],
]);
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
new Map(pids.map((pid) => [pid, pid * 1_000]));
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
createRuntimeUsageStatsByPid(pids);
const afterSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
expect(afterSwitch).toMatchObject({
@ -14293,8 +14310,8 @@ describe('Team agent launch matrix safe e2e', () => {
},
],
]);
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
new Map(pids.map((pid) => [pid, pid * 1_000]));
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
createRuntimeUsageStatsByPid(pids);
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
expect(snapshot).toMatchObject({
@ -14338,8 +14355,8 @@ describe('Team agent launch matrix safe e2e', () => {
['bob', { alive: true, pid: 64704, model: 'opencode/minimax-stale' }],
['tom', { alive: true, pid: 64705, model: 'opencode/nemotron-stale' }],
]);
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
new Map(pids.map((pid) => [pid, pid * 1_000]));
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
createRuntimeUsageStatsByPid(pids);
const staleSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
expect(staleSnapshot).toMatchObject({
@ -14463,8 +14480,8 @@ describe('Team agent launch matrix safe e2e', () => {
['bob', { alive: true, pid: 50203, model: 'sonnet-runtime' }],
]
);
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
new Map(pids.map((pid) => [pid, pid * 1_000]));
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
createRuntimeUsageStatsByPid(pids);
const firstSnapshot = await svc.getTeamAgentRuntimeSnapshot(firstTeamName);
const secondSnapshot = await svc.getTeamAgentRuntimeSnapshot(secondTeamName);
@ -14543,8 +14560,8 @@ describe('Team agent launch matrix safe e2e', () => {
]
);
};
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
new Map(pids.map((pid) => [pid, pid * 1_000]));
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
createRuntimeUsageStatsByPid(pids);
const beforeStop = await svc.getTeamAgentRuntimeSnapshot(stoppedTeamName);
expect(beforeStop.members['team-lead']).toMatchObject({

View file

@ -208,9 +208,9 @@ function createRunningChild() {
});
}
function createPidusageStat(pid: number, memory: number) {
function createPidusageStat(pid: number, memory: number, cpu = 0) {
return {
cpu: 0,
cpu,
memory,
ppid: 1,
pid,
@ -2506,6 +2506,98 @@ describe('TeamProvisioningService', () => {
});
});
it('captures CPU and memory history on runtime snapshots', async () => {
const svc = new TeamProvisioningService();
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
members: [{ name: 'team-lead', agentType: 'team-lead' }],
})),
};
(svc as any).aliveRunByTeam.set('runtime-team', 'run-1');
(svc as any).runs.set('run-1', {
runId: 'run-1',
child: { pid: 111 },
request: { model: 'gpt-5.4' },
processKilled: false,
cancelRequested: false,
spawnContext: null,
});
vi.mocked(pidusage).mockResolvedValueOnce({
'111': createPidusageStat(111, 123_000_000, 3.5),
} as any);
const firstSnapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team');
expect(firstSnapshot.members['team-lead']).toMatchObject({
pid: 111,
pidSource: 'lead_process',
cpuPercent: 3.5,
rssBytes: 123_000_000,
});
expect(firstSnapshot.members['team-lead']?.resourceHistory).toEqual([
expect.objectContaining({
cpuPercent: 3.5,
rssBytes: 123_000_000,
pidSource: 'lead_process',
pid: 111,
}),
]);
(svc as any).invalidateRuntimeSnapshotCaches('runtime-team');
vi.mocked(pidusage).mockResolvedValueOnce({
'111': createPidusageStat(111, 130_000_000, 18),
} as any);
const secondSnapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team');
expect(secondSnapshot.members['team-lead']).toMatchObject({
cpuPercent: 18,
rssBytes: 130_000_000,
});
expect(secondSnapshot.members['team-lead']?.resourceHistory).toEqual([
expect.objectContaining({
cpuPercent: 3.5,
rssBytes: 123_000_000,
pidSource: 'lead_process',
pid: 111,
}),
expect.objectContaining({
cpuPercent: 18,
rssBytes: 130_000_000,
pidSource: 'lead_process',
pid: 111,
}),
]);
});
it('caps runtime resource history per member and pid', () => {
const svc = new TeamProvisioningService();
let history: unknown[] | undefined;
for (let index = 0; index < 70; index += 1) {
history = (svc as any).recordAgentRuntimeResourceSample({
teamName: 'runtime-team',
memberName: 'alice',
timestamp: `2026-04-24T12:${String(index).padStart(2, '0')}:00.000Z`,
cpuPercent: index,
rssBytes: 100_000_000 + index,
pidSource: 'tmux_child',
pid: 222,
});
}
expect(history).toHaveLength(60);
expect(history?.[0]).toMatchObject({
cpuPercent: 10,
rssBytes: 100_000_010,
pidSource: 'tmux_child',
pid: 222,
});
expect(history?.[59]).toMatchObject({
cpuPercent: 69,
rssBytes: 100_000_069,
});
});
it('does not send legacy process backend pane markers to tmux liveness lookup', async () => {
const svc = new TeamProvisioningService();
(svc as any).configReader = {
@ -2594,7 +2686,10 @@ describe('TeamProvisioningService', () => {
})),
};
(svc as any).teamMetaStore = {
getMeta: vi.fn(async () => ({ providerId: 'anthropic', providerBackendId: 'codex-native' })),
getMeta: vi.fn(async () => ({
providerId: 'anthropic',
providerBackendId: 'codex-native',
})),
};
const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team');

View file

@ -554,6 +554,63 @@ describe('MemberCard starting-state visuals', () => {
});
});
it('renders the bottom runtime telemetry strip when resource history is available', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberCard, {
member,
memberColor: 'blue',
runtimeSummary: 'gpt-5.4-mini · Codex · 238.3 MB',
runtimeEntry: {
memberName: 'alice',
alive: true,
restartable: true,
providerId: 'codex',
pid: 222,
pidSource: 'tmux_child',
rssBytes: 238.3 * 1024 * 1024,
cpuPercent: 14,
resourceHistory: [
{
timestamp: '2026-04-24T12:00:00.000Z',
rssBytes: 220 * 1024 * 1024,
cpuPercent: 4,
pidSource: 'tmux_child',
pid: 222,
},
{
timestamp: '2026-04-24T12:00:05.000Z',
rssBytes: 238.3 * 1024 * 1024,
cpuPercent: 14,
pidSource: 'tmux_child',
pid: 222,
},
],
updatedAt: '2026-04-24T12:00:05.000Z',
},
isTeamAlive: true,
isTeamProvisioning: false,
})
);
await Promise.resolve();
});
const strip = host.querySelector('[data-testid="member-runtime-telemetry-strip"]');
expect(strip).not.toBeNull();
expect(strip?.querySelector('path[fill="#22c55e"]')).not.toBeNull();
expect(strip?.querySelector('path[stroke="#3b82f6"]')).not.toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('shows a worktree badge only for teammates configured with worktree isolation', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');

View file

@ -4274,6 +4274,55 @@ describe('teamSlice actions', () => {
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(nextSnapshot);
});
it('updates runtime snapshots when resource history changes', async () => {
const store = createSliceStore();
const firstResourceHistory = [
{
timestamp: '2026-03-12T10:00:00.000Z',
rssBytes: 256 * 1024 * 1024,
cpuPercent: 4,
pid: 4242,
},
];
const snapshot = createRuntimeSnapshot({
members: {
alice: {
...createRuntimeSnapshot().members.alice,
cpuPercent: 4,
resourceHistory: firstResourceHistory,
},
},
});
hoisted.getTeamAgentRuntime.mockResolvedValue(snapshot);
await store.getState().fetchTeamAgentRuntime('my-team');
const firstSnapshot = store.getState().teamAgentRuntimeByTeam['my-team'];
const nextSnapshot = createRuntimeSnapshot({
members: {
alice: {
...snapshot.members.alice,
cpuPercent: 14,
resourceHistory: [
...firstResourceHistory,
{
timestamp: '2026-03-12T10:00:05.000Z',
rssBytes: 270 * 1024 * 1024,
cpuPercent: 14,
pid: 4242,
},
],
},
},
});
hoisted.getTeamAgentRuntime.mockResolvedValue(nextSnapshot);
await store.getState().fetchTeamAgentRuntime('my-team');
expect(store.getState().teamAgentRuntimeByTeam['my-team']).not.toBe(firstSnapshot);
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(nextSnapshot);
});
it('updates runtime snapshots when copy-diagnostics details change', async () => {
const store = createSliceStore();
const snapshot = createRuntimeSnapshot({

View file

@ -15,9 +15,15 @@ const sentryNoOp = {
init: vi.fn(),
addBreadcrumb: vi.fn(),
captureException: vi.fn(),
setUser: vi.fn(),
setTags: vi.fn(),
startSpan: vi.fn((_opts: unknown, fn: () => unknown) => fn()),
withScope: vi.fn((fn: (scope: unknown) => void) => fn({ setContext: vi.fn() })),
browserTracingIntegration: vi.fn(() => ({ name: 'BrowserTracing', setup: vi.fn(), afterAllSetup: vi.fn() })),
browserTracingIntegration: vi.fn(() => ({
name: 'BrowserTracing',
setup: vi.fn(),
afterAllSetup: vi.fn(),
})),
};
vi.mock('@sentry/electron/main', () => sentryNoOp);
vi.mock('@sentry/electron/renderer', () => sentryNoOp);
@ -47,8 +53,8 @@ function formatConsoleCall(args: unknown[]): string {
}
beforeEach(() => {
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {