diff --git a/docs/team-management/assets/team-member-runtime-telemetry-variant-b-reference.png b/docs/team-management/assets/team-member-runtime-telemetry-variant-b-reference.png new file mode 100644 index 00000000..29145dfe Binary files /dev/null and b/docs/team-management/assets/team-member-runtime-telemetry-variant-b-reference.png differ diff --git a/docs/team-management/member-runtime-telemetry-reference.md b/docs/team-management/member-runtime-telemetry-reference.md new file mode 100644 index 00000000..02302125 --- /dev/null +++ b/docs/team-management/member-runtime-telemetry-reference.md @@ -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. diff --git a/src/main/index.ts b/src/main/index.ts index d63e37d8..d11b4bd8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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); diff --git a/src/main/sentry.ts b/src/main/sentry.ts index e00ec248..a0b35167 100644 --- a/src/main/sentry.ts +++ b/src/main/sentry.ts @@ -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 { + 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) => void; + addBreadcrumb?: (breadcrumb: { + category: string; + message: string; + data?: Record; + level: 'info'; + }) => void; + startSpan?: (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 { + 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 ): 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(name: string, op: string, fn: () => T): T { if (!initialized) return fn(); + if (!Sentry?.startSpan) return fn(); return Sentry.startSpan({ name, op }, fn); } diff --git a/src/main/services/identity/AgentTeamsIdentityStore.ts b/src/main/services/identity/AgentTeamsIdentityStore.ts new file mode 100644 index 00000000..cd1ee5de --- /dev/null +++ b/src/main/services/identity/AgentTeamsIdentityStore.ts @@ -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; + capabilities?: Record; + createdAt: string; + updatedAt: string; +} + +export interface AgentTeamsClientIdentity { + clientId: string; + source: AgentTeamsIdentitySource; + storePath: string; +} + +interface LegacyAgentTeamsState { + clientId: string; + session?: Record; + capabilities?: Record; +} + +function isRecord(value: unknown): value is Record { + 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, + key: string +): Record | 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 { + 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 { + 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 { + 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 { + 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 { + return normalizeStoreRecord(await readJsonFile(storePath)); +} + +export async function ensureAgentTeamsClientIdentity(options?: { + storePath?: string; +}): Promise { + 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 { + return loadAppDataIdentity(options?.storePath ?? getAgentTeamsIdentityStorePath()); +} diff --git a/src/main/services/runtime/buildRuntimeBaseEnv.ts b/src/main/services/runtime/buildRuntimeBaseEnv.ts index 11443588..7da7e0e4 100644 --- a/src/main/services/runtime/buildRuntimeBaseEnv.ts +++ b/src/main/services/runtime/buildRuntimeBaseEnv.ts @@ -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; diff --git a/src/main/services/team/AgentTeamsMcpHttpServer.ts b/src/main/services/team/AgentTeamsMcpHttpServer.ts index 60c4ebc3..f8faafca 100644 --- a/src/main/services/team/AgentTeamsMcpHttpServer.ts +++ b/src/main/services/team/AgentTeamsMcpHttpServer.ts @@ -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) { diff --git a/src/main/services/team/TaskChangeComputer.ts b/src/main/services/team/TaskChangeComputer.ts index 4314e821..48c7a486 100644 --- a/src/main/services/team/TaskChangeComputer.ts +++ b/src/main/services/team/TaskChangeComputer.ts @@ -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 { + 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] + : [], }; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 1ec3cbae..e80d4fc3 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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 + >(); 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 = {}; + const activeResourceHistoryKeys = new Set(); 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> { + 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; + }): 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(); + 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 + ): 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> { const uniquePids = [...new Set(pids.filter((pid) => Number.isFinite(pid) && pid > 0))]; if (uniquePids.length === 0) { return new Map(); } - const rssBytesByPid = new Map(); + const usageStatsByPid = new Map(); 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( diff --git a/src/main/utils/cliEnv.ts b/src/main/utils/cliEnv.ts index b39d76ed..be16ffa9 100644 --- a/src/main/utils/cliEnv.ts +++ b/src/main/utils/cliEnv.ts @@ -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, } : {}), - }; + }); } diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 86ddfe80..d9554b42 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -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 ( +