feat: add telemetry identity and runtime status
This commit is contained in:
parent
445932e45b
commit
4ec745268b
24 changed files with 1405 additions and 90 deletions
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
12
docs/team-management/member-runtime-telemetry-reference.md
Normal file
12
docs/team-management/member-runtime-telemetry-reference.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Member Runtime Telemetry Reference
|
||||
|
||||
Design reference for participant-card runtime telemetry:
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
218
src/main/services/identity/AgentTeamsIdentityStore.ts
Normal file
218
src/main/services/identity/AgentTeamsIdentityStore.ts
Normal 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());
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
42
test/main/sentry.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
130
test/main/services/identity/AgentTeamsIdentityStore.test.ts
Normal file
130
test/main/services/identity/AgentTeamsIdentityStore.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue