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,
|
SkillsMutationService,
|
||||||
SkillsWatcherService,
|
SkillsWatcherService,
|
||||||
} from './services/extensions';
|
} from './services/extensions';
|
||||||
|
import { applyAgentTeamsIdentityEnv } from './services/identity/AgentTeamsIdentityStore';
|
||||||
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
||||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||||
import { clearAutoResumeService } from './services/team/AutoResumeService';
|
import { clearAutoResumeService } from './services/team/AutoResumeService';
|
||||||
|
|
@ -358,6 +359,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
||||||
|
|
||||||
reportProgress('runtime-environment', 'Preparing runtime environment...');
|
reportProgress('runtime-environment', 'Preparing runtime environment...');
|
||||||
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
|
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
|
||||||
|
applyAgentTeamsIdentityEnv(bridgeEnv);
|
||||||
bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId;
|
bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId;
|
||||||
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
|
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
|
||||||
const useHttpMcpBridge = isOpenCodeMcpHttpBridgeEnabled(bridgeEnv);
|
const useHttpMcpBridge = isOpenCodeMcpHttpBridgeEnabled(bridgeEnv);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,11 @@
|
||||||
* loaded in standalone (non-Electron) mode without crashing.
|
* loaded in standalone (non-Electron) mode without crashing.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AgentTeamsIdentitySource,
|
||||||
|
ensureAgentTeamsClientIdentity,
|
||||||
|
getSentryAnonymousUserId,
|
||||||
|
} from '@main/services/identity/AgentTeamsIdentityStore';
|
||||||
import {
|
import {
|
||||||
isValidDsn,
|
isValidDsn,
|
||||||
SENTRY_ENVIRONMENT,
|
SENTRY_ENVIRONMENT,
|
||||||
|
|
@ -26,6 +31,18 @@ import {
|
||||||
// Defaults to `true` so early crash reports are NOT silently dropped;
|
// Defaults to `true` so early crash reports are NOT silently dropped;
|
||||||
// if the user later turns telemetry off, the flag flips to `false`.
|
// if the user later turns telemetry off, the flag flips to `false`.
|
||||||
let telemetryAllowed = true;
|
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.
|
* Call once ConfigManager is initialised to sync the opt-in flag.
|
||||||
|
|
@ -33,16 +50,80 @@ let telemetryAllowed = true;
|
||||||
*/
|
*/
|
||||||
export function syncTelemetryFlag(enabled: boolean): void {
|
export function syncTelemetryFlag(enabled: boolean): void {
|
||||||
telemetryAllowed = enabled;
|
telemetryAllowed = enabled;
|
||||||
|
void syncTelemetryIdentity();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterSentryEventForTelemetry(event: unknown): unknown {
|
||||||
|
return telemetryAllowed ? event : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Lazy Sentry import — safe in non-Electron environments
|
// Lazy Sentry import — safe in non-Electron environments
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
interface SentryMainApi {
|
||||||
let Sentry: any = null;
|
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;
|
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;
|
const dsn = process.env.SENTRY_DSN;
|
||||||
|
|
||||||
if (isValidDsn(dsn)) {
|
if (isValidDsn(dsn)) {
|
||||||
|
|
@ -51,18 +132,17 @@ if (isValidDsn(dsn)) {
|
||||||
// in all contexts. require() is synchronous and works in both Electron
|
// in all contexts. require() is synchronous and works in both Electron
|
||||||
// and Node.js — it simply throws in standalone mode where the electron
|
// and Node.js — it simply throws in standalone mode where the electron
|
||||||
// module is not resolvable.
|
// module is not resolvable.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy optional Electron runtime dependency.
|
||||||
Sentry = require('@sentry/electron/main');
|
Sentry = require('@sentry/electron/main') as SentryMainApi;
|
||||||
Sentry.init({
|
Sentry.init?.({
|
||||||
dsn,
|
dsn,
|
||||||
release: SENTRY_RELEASE,
|
release: SENTRY_RELEASE,
|
||||||
environment: SENTRY_ENVIRONMENT,
|
environment: SENTRY_ENVIRONMENT,
|
||||||
tracesSampleRate: TRACES_SAMPLE_RATE,
|
tracesSampleRate: TRACES_SAMPLE_RATE,
|
||||||
sendDefaultPii: false,
|
sendDefaultPii: false,
|
||||||
|
|
||||||
beforeSend(event: unknown) {
|
beforeSend: filterSentryEventForTelemetry,
|
||||||
return telemetryAllowed ? event : null;
|
beforeSendTransaction: filterSentryEventForTelemetry,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
initialized = true;
|
initialized = true;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -83,7 +163,7 @@ export function addMainBreadcrumb(
|
||||||
data?: Record<string, unknown>
|
data?: Record<string, unknown>
|
||||||
): void {
|
): void {
|
||||||
if (!initialized) return;
|
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 {
|
export function startMainSpan<T>(name: string, op: string, fn: () => T): T {
|
||||||
if (!initialized) return fn();
|
if (!initialized) return fn();
|
||||||
|
if (!Sentry?.startSpan) return fn();
|
||||||
return Sentry.startSpan({ name, op }, 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 { buildEnrichedEnv } from '@main/utils/cliEnv';
|
||||||
import { getShellPreferredHome } from '@main/utils/shellEnv';
|
import { getShellPreferredHome } from '@main/utils/shellEnv';
|
||||||
|
|
||||||
|
|
@ -43,6 +44,7 @@ export function buildRuntimeBaseEnv(options: BuildRuntimeBaseEnvOptions = {}): {
|
||||||
|
|
||||||
applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime);
|
applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime);
|
||||||
Object.assign(env, options.env ?? {});
|
Object.assign(env, options.env ?? {});
|
||||||
|
applyAgentTeamsIdentityEnv(env);
|
||||||
const policyAppliedEnv = applyOpenCodeAutoUpdatePolicy(env);
|
const policyAppliedEnv = applyOpenCodeAutoUpdatePolicy(env);
|
||||||
if (policyAppliedEnv.OPENCODE_DISABLE_AUTOUPDATE === undefined) {
|
if (policyAppliedEnv.OPENCODE_DISABLE_AUTOUPDATE === undefined) {
|
||||||
delete env.OPENCODE_DISABLE_AUTOUPDATE;
|
delete env.OPENCODE_DISABLE_AUTOUPDATE;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { applyAgentTeamsIdentityEnv } from '@main/services/identity/AgentTeamsIdentityStore';
|
||||||
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
|
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
|
||||||
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
@ -153,7 +154,7 @@ export class AgentTeamsMcpHttpServer {
|
||||||
const launchSpec = await resolveLaunchSpec();
|
const launchSpec = await resolveLaunchSpec();
|
||||||
const port = await allocatePort();
|
const port = await allocatePort();
|
||||||
const args = buildHttpServerArgs(launchSpec, port);
|
const args = buildHttpServerArgs(launchSpec, port);
|
||||||
const child = spawnProcess(launchSpec.command, args, {
|
const childEnv = applyAgentTeamsIdentityEnv({
|
||||||
...process.env,
|
...process.env,
|
||||||
AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(),
|
AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(),
|
||||||
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
|
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
|
||||||
|
|
@ -161,6 +162,7 @@ export class AgentTeamsMcpHttpServer {
|
||||||
AGENT_TEAMS_MCP_HTTP_PORT: String(port),
|
AGENT_TEAMS_MCP_HTTP_PORT: String(port),
|
||||||
AGENT_TEAMS_MCP_HTTP_ENDPOINT: MCP_HTTP_ENDPOINT,
|
AGENT_TEAMS_MCP_HTTP_ENDPOINT: MCP_HTTP_ENDPOINT,
|
||||||
});
|
});
|
||||||
|
const child = spawnProcess(launchSpec.command, args, childEnv);
|
||||||
|
|
||||||
const clearIfCurrent = (): void => {
|
const clearIfCurrent = (): void => {
|
||||||
if (this.child === child) {
|
if (this.child === child) {
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,9 @@ interface ParsedJsonlEntry {
|
||||||
lineNumber: number;
|
lineNumber: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldWarnAboutMissingTaskLogs(input: ResolvedTaskChangeComputeInput): boolean {
|
function shouldWarnAboutUnavailableTaskChangeEvidence(
|
||||||
|
input: ResolvedTaskChangeComputeInput
|
||||||
|
): boolean {
|
||||||
const status = input.taskMeta?.status?.trim() || input.effectiveOptions.status?.trim();
|
const status = input.taskMeta?.status?.trim() || input.effectiveOptions.status?.trim();
|
||||||
const stateBucket = getTaskChangeStateBucket({
|
const stateBucket = getTaskChangeStateBucket({
|
||||||
status,
|
status,
|
||||||
|
|
@ -141,7 +143,7 @@ export class TaskChangeComputer {
|
||||||
});
|
});
|
||||||
if (intervalScoped) return intervalScoped;
|
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);
|
const files = await this.extractScopedChanges(logRefs, allScopes, projectPath, includeDetails);
|
||||||
|
|
@ -418,17 +420,17 @@ export class TaskChangeComputer {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fallbackSingleTaskScope(
|
private async fallbackSingleTaskScope(
|
||||||
teamName: string,
|
input: ResolvedTaskChangeComputeInput,
|
||||||
taskId: string,
|
logRefs: LogFileRef[]
|
||||||
logRefs: LogFileRef[],
|
|
||||||
projectPath?: string,
|
|
||||||
includeDetails = true
|
|
||||||
): Promise<TaskChangeSetV2> {
|
): Promise<TaskChangeSetV2> {
|
||||||
|
const { teamName, taskId, projectPath, includeDetails } = input;
|
||||||
const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath));
|
const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath));
|
||||||
const allSnippets = this.sortSnippetsChronologically(
|
const allSnippets = this.sortSnippetsChronologically(
|
||||||
allParsed.flatMap((result) => result.snippets.map((record) => record.snippet))
|
allParsed.flatMap((result) => result.snippets.map((record) => record.snippet))
|
||||||
);
|
);
|
||||||
const aggregatedFiles = this.aggregateByFile(allSnippets, projectPath, includeDetails);
|
const aggregatedFiles = this.aggregateByFile(allSnippets, projectPath, includeDetails);
|
||||||
|
const shouldWarn =
|
||||||
|
aggregatedFiles.length > 0 || shouldWarnAboutUnavailableTaskChangeEvidence(input);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
teamName,
|
teamName,
|
||||||
|
|
@ -450,7 +452,9 @@ export class TaskChangeComputer {
|
||||||
filePaths: aggregatedFiles.map((file) => file.filePath),
|
filePaths: aggregatedFiles.map((file) => file.filePath),
|
||||||
confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' },
|
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: [],
|
filePaths: [],
|
||||||
confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' },
|
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,
|
TeamAgentRuntimeEntry,
|
||||||
TeamAgentRuntimeLivenessKind,
|
TeamAgentRuntimeLivenessKind,
|
||||||
TeamAgentRuntimePidSource,
|
TeamAgentRuntimePidSource,
|
||||||
|
TeamAgentRuntimeResourceSample,
|
||||||
TeamAgentRuntimeSnapshot,
|
TeamAgentRuntimeSnapshot,
|
||||||
TeamChangeEvent,
|
TeamChangeEvent,
|
||||||
TeamConfig,
|
TeamConfig,
|
||||||
|
|
@ -577,6 +578,11 @@ import type {
|
||||||
// the first sample can expire before the recursive second read and loop again.
|
// the first sample can expire before the recursive second read and loop again.
|
||||||
const RUNTIME_PIDUSAGE_OPTIONS = process.platform === 'win32' ? { maxage: 10_000 } : { maxage: 0 };
|
const RUNTIME_PIDUSAGE_OPTIONS = process.platform === 'win32' ? { maxage: 10_000 } : { maxage: 0 };
|
||||||
|
|
||||||
|
interface RuntimeProcessUsageStats {
|
||||||
|
rssBytes?: number;
|
||||||
|
cpuPercent?: number;
|
||||||
|
}
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamProvisioning');
|
const logger = createLogger('Service:TeamProvisioning');
|
||||||
const PREFLIGHT_DEBUG_LOG_PATH = path.join(os.tmpdir(), 'claude-team-preflight-debug.log');
|
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_RUN_START_SKEW_MS = 1_000;
|
||||||
private static readonly SAME_TEAM_PERSIST_RETRY_MS = 2_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_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 MEMBER_SPAWN_STATUS_SNAPSHOT_CACHE_TTL_MS = 500;
|
||||||
private static readonly LAUNCH_STATE_NOOP_REFRESH_MS = 15_000;
|
private static readonly LAUNCH_STATE_NOOP_REFRESH_MS = 15_000;
|
||||||
private static readonly RETAINED_PROVISIONING_PROGRESS_TTL_MS = 5 * 60_000;
|
private static readonly RETAINED_PROVISIONING_PROGRESS_TTL_MS = 5 * 60_000;
|
||||||
|
|
@ -5926,6 +5933,10 @@ export class TeamProvisioningService {
|
||||||
string,
|
string,
|
||||||
{ expiresAtMs: number; snapshot: TeamAgentRuntimeSnapshot }
|
{ expiresAtMs: number; snapshot: TeamAgentRuntimeSnapshot }
|
||||||
>();
|
>();
|
||||||
|
private readonly agentRuntimeResourceHistoryByTeam = new Map<
|
||||||
|
string,
|
||||||
|
Map<string, TeamAgentRuntimeResourceSample[]>
|
||||||
|
>();
|
||||||
private readonly agentRuntimeSnapshotInFlightByTeam = new Map<
|
private readonly agentRuntimeSnapshotInFlightByTeam = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
|
|
@ -15127,9 +15138,10 @@ export class TeamProvisioningService {
|
||||||
runtimePids.add(memberPid);
|
runtimePids.add(memberPid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const rssBytesByPid = await this.readProcessRssBytesByPid([...runtimePids]);
|
const usageStatsByPid = await this.readProcessUsageStatsByPid([...runtimePids]);
|
||||||
const persistedRuntimeMembers = this.readPersistedRuntimeMembers(teamName);
|
const persistedRuntimeMembers = this.readPersistedRuntimeMembers(teamName);
|
||||||
const snapshotMembers: Record<string, TeamAgentRuntimeEntry> = {};
|
const snapshotMembers: Record<string, TeamAgentRuntimeEntry> = {};
|
||||||
|
const activeResourceHistoryKeys = new Set<string>();
|
||||||
|
|
||||||
const getPersistedRuntimeMember = (
|
const getPersistedRuntimeMember = (
|
||||||
memberName: string
|
memberName: string
|
||||||
|
|
@ -15221,7 +15233,7 @@ export class TeamProvisioningService {
|
||||||
const isLead = isLeadMember({ name: memberName, agentType: member.agentType });
|
const isLead = isLeadMember({ name: memberName, agentType: member.agentType });
|
||||||
if (isLead) {
|
if (isLead) {
|
||||||
const pid = run?.child?.pid;
|
const pid = run?.child?.pid;
|
||||||
const rssBytes = pid ? rssBytesByPid.get(pid) : undefined;
|
const usageStats = pid ? usageStatsByPid.get(pid) : undefined;
|
||||||
const runtimeModel =
|
const runtimeModel =
|
||||||
run?.request.model?.trim() ||
|
run?.request.model?.trim() ||
|
||||||
(run?.spawnContext
|
(run?.spawnContext
|
||||||
|
|
@ -15229,6 +15241,18 @@ export class TeamProvisioningService {
|
||||||
: undefined) ||
|
: undefined) ||
|
||||||
member.model?.trim() ||
|
member.model?.trim() ||
|
||||||
undefined;
|
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] = {
|
snapshotMembers[memberName] = {
|
||||||
memberName,
|
memberName,
|
||||||
alive: Boolean(pid && !run?.processKilled && !run?.cancelRequested),
|
alive: Boolean(pid && !run?.processKilled && !run?.cancelRequested),
|
||||||
|
|
@ -15236,7 +15260,10 @@ export class TeamProvisioningService {
|
||||||
backendType: 'lead',
|
backendType: 'lead',
|
||||||
...(pid ? { pid } : {}),
|
...(pid ? { pid } : {}),
|
||||||
...(runtimeModel ? { runtimeModel } : {}),
|
...(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,
|
updatedAt,
|
||||||
};
|
};
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -15342,18 +15369,32 @@ export class TeamProvisioningService {
|
||||||
liveRuntimeMember?.livenessKind === 'runtime_process_candidate'
|
liveRuntimeMember?.livenessKind === 'runtime_process_candidate'
|
||||||
? 'info'
|
? 'info'
|
||||||
: liveRuntimeMember?.runtimeDiagnosticSeverity;
|
: liveRuntimeMember?.runtimeDiagnosticSeverity;
|
||||||
let rssBytes = rssPid ? rssBytesByPid.get(rssPid) : undefined;
|
let usageStats = rssPid ? usageStatsByPid.get(rssPid) : undefined;
|
||||||
if (rssBytes == null && isSharedOpenCodeHost && typeof rssPid === 'number' && rssPid > 0) {
|
if (!usageStats && isSharedOpenCodeHost && typeof rssPid === 'number' && rssPid > 0) {
|
||||||
try {
|
try {
|
||||||
const refreshedStat = await pidusage(rssPid, RUNTIME_PIDUSAGE_OPTIONS);
|
const refreshedStat = await pidusage(rssPid, RUNTIME_PIDUSAGE_OPTIONS);
|
||||||
if (Number.isFinite(refreshedStat.memory) && refreshedStat.memory >= 0) {
|
const refreshedUsageStats = this.normalizeRuntimeProcessUsageStats(refreshedStat);
|
||||||
rssBytesByPid.set(rssPid, refreshedStat.memory);
|
if (refreshedUsageStats) {
|
||||||
rssBytes = refreshedStat.memory;
|
usageStatsByPid.set(rssPid, refreshedUsageStats);
|
||||||
|
usageStats = refreshedUsageStats;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Shared OpenCode host can exit between discovery and the targeted RSS refresh.
|
// 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] = {
|
snapshotMembers[memberName] = {
|
||||||
memberName,
|
memberName,
|
||||||
|
|
@ -15367,7 +15408,9 @@ export class TeamProvisioningService {
|
||||||
...(displayPid ? { pid: displayPid } : {}),
|
...(displayPid ? { pid: displayPid } : {}),
|
||||||
...(runtimeModel ? { runtimeModel } : {}),
|
...(runtimeModel ? { runtimeModel } : {}),
|
||||||
...(runtimeCwd ? { cwd: runtimeCwd } : {}),
|
...(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 } : {}),
|
...(effectiveLivenessKind ? { livenessKind: effectiveLivenessKind } : {}),
|
||||||
...(liveRuntimeMember?.pidSource ? { pidSource: liveRuntimeMember.pidSource } : {}),
|
...(liveRuntimeMember?.pidSource ? { pidSource: liveRuntimeMember.pidSource } : {}),
|
||||||
...(liveRuntimeMember?.processCommand
|
...(liveRuntimeMember?.processCommand
|
||||||
|
|
@ -15394,6 +15437,7 @@ export class TeamProvisioningService {
|
||||||
updatedAt,
|
updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
this.pruneAgentRuntimeResourceHistory(teamName, activeResourceHistoryKeys);
|
||||||
|
|
||||||
const persistedLaunchIdentity = persistedTeamMeta?.launchIdentity;
|
const persistedLaunchIdentity = persistedTeamMeta?.launchIdentity;
|
||||||
const snapshotProviderId =
|
const snapshotProviderId =
|
||||||
|
|
@ -25624,24 +25668,137 @@ export class TeamProvisioningService {
|
||||||
return metadataByMember;
|
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))];
|
const uniquePids = [...new Set(pids.filter((pid) => Number.isFinite(pid) && pid > 0))];
|
||||||
if (uniquePids.length === 0) {
|
if (uniquePids.length === 0) {
|
||||||
return new Map();
|
return new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
const rssBytesByPid = new Map<number, number>();
|
const usageStatsByPid = new Map<number, RuntimeProcessUsageStats>();
|
||||||
const options = RUNTIME_PIDUSAGE_OPTIONS;
|
const options = RUNTIME_PIDUSAGE_OPTIONS;
|
||||||
try {
|
try {
|
||||||
const statsByPid = await pidusage(uniquePids, options);
|
const statsByPid = await pidusage(uniquePids, options);
|
||||||
for (const [rawPid, stat] of Object.entries(statsByPid)) {
|
for (const [rawPid, stat] of Object.entries(statsByPid)) {
|
||||||
const pid = Number.parseInt(rawPid, 10);
|
const pid = Number.parseInt(rawPid, 10);
|
||||||
const rssBytes = stat?.memory;
|
const usageStats = this.normalizeRuntimeProcessUsageStats(stat);
|
||||||
if (Number.isFinite(pid) && pid > 0 && Number.isFinite(rssBytes) && rssBytes >= 0) {
|
if (Number.isFinite(pid) && pid > 0 && usageStats) {
|
||||||
rssBytesByPid.set(pid, rssBytes);
|
usageStatsByPid.set(pid, usageStats);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rssBytesByPid;
|
return usageStatsByPid;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`pidusage batch runtime snapshot failed; falling back to per-pid reads: ${
|
`pidusage batch runtime snapshot failed; falling back to per-pid reads: ${
|
||||||
|
|
@ -25654,15 +25811,16 @@ export class TeamProvisioningService {
|
||||||
uniquePids.map(async (pid) => {
|
uniquePids.map(async (pid) => {
|
||||||
try {
|
try {
|
||||||
const stat = await pidusage(pid, options);
|
const stat = await pidusage(pid, options);
|
||||||
if (Number.isFinite(stat.memory) && stat.memory >= 0) {
|
const usageStats = this.normalizeRuntimeProcessUsageStats(stat);
|
||||||
rssBytesByPid.set(pid, stat.memory);
|
if (usageStats) {
|
||||||
|
usageStatsByPid.set(pid, usageStats);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Process likely exited between discovery and sampling.
|
// Process likely exited between discovery and sampling.
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return rssBytesByPid;
|
return usageStatsByPid;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async clearPersistedLaunchState(
|
private async clearPersistedLaunchState(
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
* can find the tools they need and authenticate properly.
|
* can find the tools they need and authenticate properly.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { applyAgentTeamsIdentityEnv } from '@main/services/identity/AgentTeamsIdentityStore';
|
||||||
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
|
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
|
||||||
import { getAutoDetectedClaudeBasePath, getClaudeBasePath } from '@main/utils/pathDecoder';
|
import { getAutoDetectedClaudeBasePath, getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||||
import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv';
|
import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv';
|
||||||
|
|
@ -36,7 +37,7 @@ export function buildEnrichedEnv(binaryPath?: string | null): NodeJS.ProcessEnv
|
||||||
const configDir = getClaudeBasePath();
|
const configDir = getClaudeBasePath();
|
||||||
const isCustomConfigDir = configDir !== getAutoDetectedClaudeBasePath();
|
const isCustomConfigDir = configDir !== getAutoDetectedClaudeBasePath();
|
||||||
|
|
||||||
return {
|
return applyAgentTeamsIdentityEnv({
|
||||||
...process.env,
|
...process.env,
|
||||||
...(shellEnv ?? {}),
|
...(shellEnv ?? {}),
|
||||||
HOME: home,
|
HOME: home,
|
||||||
|
|
@ -49,5 +50,5 @@ export function buildEnrichedEnv(binaryPath?: string | null): NodeJS.ProcessEnv
|
||||||
LOGNAME: shellEnv?.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user,
|
LOGNAME: shellEnv?.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import type {
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
TeamAgentRuntimeEntry,
|
TeamAgentRuntimeEntry,
|
||||||
|
TeamAgentRuntimeResourceSample,
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -114,6 +115,186 @@ function getLaunchFailureLinkLabel(url: string): string {
|
||||||
return url;
|
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({
|
export const MemberCard = memo(function MemberCard({
|
||||||
member,
|
member,
|
||||||
memberColor,
|
memberColor,
|
||||||
|
|
@ -374,7 +555,10 @@ export const MemberCard = memo(function MemberCard({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<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}
|
style={undefined}
|
||||||
title={activityTitle}
|
title={activityTitle}
|
||||||
role="button"
|
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" />
|
{!isRemoved ? <MemberRuntimeTelemetryStrip runtimeEntry={runtimeEntry} /> : null}
|
||||||
<div className="flex items-center gap-2.5">
|
<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="relative shrink-0">
|
||||||
<div
|
<div
|
||||||
className="rounded-full border-2 p-px"
|
className="rounded-full border-2 p-px"
|
||||||
|
|
|
||||||
|
|
@ -275,6 +275,8 @@ function areMemberRuntimeEntriesEquivalent(
|
||||||
const rightEntry = right.get(key);
|
const rightEntry = right.get(key);
|
||||||
const leftDiagnostics = leftEntry.diagnostics ?? [];
|
const leftDiagnostics = leftEntry.diagnostics ?? [];
|
||||||
const rightDiagnostics = rightEntry?.diagnostics ?? [];
|
const rightDiagnostics = rightEntry?.diagnostics ?? [];
|
||||||
|
const leftResourceHistory = leftEntry.resourceHistory ?? [];
|
||||||
|
const rightResourceHistory = rightEntry?.resourceHistory ?? [];
|
||||||
if (
|
if (
|
||||||
leftEntry.memberName !== rightEntry?.memberName ||
|
leftEntry.memberName !== rightEntry?.memberName ||
|
||||||
leftEntry.alive !== rightEntry?.alive ||
|
leftEntry.alive !== rightEntry?.alive ||
|
||||||
|
|
@ -287,6 +289,7 @@ function areMemberRuntimeEntriesEquivalent(
|
||||||
leftEntry.pid !== rightEntry?.pid ||
|
leftEntry.pid !== rightEntry?.pid ||
|
||||||
leftEntry.runtimeModel !== rightEntry?.runtimeModel ||
|
leftEntry.runtimeModel !== rightEntry?.runtimeModel ||
|
||||||
leftEntry.rssBytes !== rightEntry?.rssBytes ||
|
leftEntry.rssBytes !== rightEntry?.rssBytes ||
|
||||||
|
leftEntry.cpuPercent !== rightEntry?.cpuPercent ||
|
||||||
leftEntry.livenessKind !== rightEntry?.livenessKind ||
|
leftEntry.livenessKind !== rightEntry?.livenessKind ||
|
||||||
leftEntry.pidSource !== rightEntry?.pidSource ||
|
leftEntry.pidSource !== rightEntry?.pidSource ||
|
||||||
leftEntry.processCommand !== rightEntry?.processCommand ||
|
leftEntry.processCommand !== rightEntry?.processCommand ||
|
||||||
|
|
@ -300,7 +303,19 @@ function areMemberRuntimeEntriesEquivalent(
|
||||||
leftEntry.runtimeLastSeenAt !== rightEntry?.runtimeLastSeenAt ||
|
leftEntry.runtimeLastSeenAt !== rightEntry?.runtimeLastSeenAt ||
|
||||||
leftEntry.historicalBootstrapConfirmed !== rightEntry?.historicalBootstrapConfirmed ||
|
leftEntry.historicalBootstrapConfirmed !== rightEntry?.historicalBootstrapConfirmed ||
|
||||||
leftDiagnostics.length !== rightDiagnostics.length ||
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ let initialized = false;
|
||||||
*/
|
*/
|
||||||
export function syncRendererTelemetry(enabled: boolean): void {
|
export function syncRendererTelemetry(enabled: boolean): void {
|
||||||
telemetryAllowed = enabled;
|
telemetryAllowed = enabled;
|
||||||
|
if (!enabled && initialized && typeof SentryElectron.setUser === 'function') {
|
||||||
|
SentryElectron.setUser(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initSentryRenderer(): void {
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- cross-version @sentry/core type mismatch
|
||||||
const beforeSend = (event: any): any => (telemetryAllowed ? event : null);
|
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) {
|
if (window.electronAPI) {
|
||||||
// Electron renderer — uses IPC transport to main process.
|
// Electron renderer — uses IPC transport to main process.
|
||||||
|
|
@ -57,6 +62,7 @@ export function initSentryRenderer(): void {
|
||||||
SentryElectron.init({
|
SentryElectron.init({
|
||||||
...baseOptions,
|
...baseOptions,
|
||||||
beforeSend,
|
beforeSend,
|
||||||
|
beforeSendTransaction,
|
||||||
integrations: [SentryElectron.browserTracingIntegration()],
|
integrations: [SentryElectron.browserTracingIntegration()],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -64,6 +70,7 @@ export function initSentryRenderer(): void {
|
||||||
reactInit({
|
reactInit({
|
||||||
...baseOptions,
|
...baseOptions,
|
||||||
beforeSend,
|
beforeSend,
|
||||||
|
beforeSendTransaction,
|
||||||
integrations: [reactBrowserTracing()],
|
integrations: [reactBrowserTracing()],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -986,6 +986,8 @@ function areTeamAgentRuntimeEntriesEqual(
|
||||||
if (!left || !right) return left === right;
|
if (!left || !right) return left === right;
|
||||||
const leftDiagnostics = left.diagnostics ?? [];
|
const leftDiagnostics = left.diagnostics ?? [];
|
||||||
const rightDiagnostics = right.diagnostics ?? [];
|
const rightDiagnostics = right.diagnostics ?? [];
|
||||||
|
const leftResourceHistory = left.resourceHistory ?? [];
|
||||||
|
const rightResourceHistory = right.resourceHistory ?? [];
|
||||||
return (
|
return (
|
||||||
left.memberName === right.memberName &&
|
left.memberName === right.memberName &&
|
||||||
left.alive === right.alive &&
|
left.alive === right.alive &&
|
||||||
|
|
@ -998,6 +1000,7 @@ function areTeamAgentRuntimeEntriesEqual(
|
||||||
left.pid === right.pid &&
|
left.pid === right.pid &&
|
||||||
left.runtimeModel === right.runtimeModel &&
|
left.runtimeModel === right.runtimeModel &&
|
||||||
left.rssBytes === right.rssBytes &&
|
left.rssBytes === right.rssBytes &&
|
||||||
|
left.cpuPercent === right.cpuPercent &&
|
||||||
left.livenessKind === right.livenessKind &&
|
left.livenessKind === right.livenessKind &&
|
||||||
left.pidSource === right.pidSource &&
|
left.pidSource === right.pidSource &&
|
||||||
left.processCommand === right.processCommand &&
|
left.processCommand === right.processCommand &&
|
||||||
|
|
@ -1011,7 +1014,19 @@ function areTeamAgentRuntimeEntriesEqual(
|
||||||
left.runtimeLastSeenAt === right.runtimeLastSeenAt &&
|
left.runtimeLastSeenAt === right.runtimeLastSeenAt &&
|
||||||
left.historicalBootstrapConfirmed === right.historicalBootstrapConfirmed &&
|
left.historicalBootstrapConfirmed === right.historicalBootstrapConfirmed &&
|
||||||
leftDiagnostics.length === rightDiagnostics.length &&
|
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 type TeamAgentRuntimeDiagnosticSeverity = 'info' | 'warning' | 'error';
|
||||||
|
|
||||||
|
export interface TeamAgentRuntimeResourceSample {
|
||||||
|
timestamp: string;
|
||||||
|
cpuPercent?: number;
|
||||||
|
rssBytes?: number;
|
||||||
|
pidSource?: TeamAgentRuntimePidSource;
|
||||||
|
pid?: number;
|
||||||
|
runtimePid?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TeamAgentRuntimeEntry {
|
export interface TeamAgentRuntimeEntry {
|
||||||
memberName: string;
|
memberName: string;
|
||||||
alive: boolean;
|
alive: boolean;
|
||||||
|
|
@ -1213,6 +1222,8 @@ export interface TeamAgentRuntimeEntry {
|
||||||
/** Runtime working directory, when known. */
|
/** Runtime working directory, when known. */
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
rssBytes?: number;
|
rssBytes?: number;
|
||||||
|
cpuPercent?: number;
|
||||||
|
resourceHistory?: TeamAgentRuntimeResourceSample[];
|
||||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||||
pidSource?: TeamAgentRuntimePidSource;
|
pidSource?: TeamAgentRuntimePidSource;
|
||||||
processCommand?: string;
|
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);
|
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 () => {
|
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-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||||
setClaudeBasePathOverride(tmpDir);
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,28 @@ import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
|
|
||||||
import { TaskChangeComputer } from '../../../../src/main/services/team/TaskChangeComputer';
|
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> {
|
async function writeJsonl(filePath: string, entries: object[]): Promise<void> {
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
|
|
@ -99,6 +121,37 @@ function createNoLogTaskChangeComputer(): TaskChangeComputer {
|
||||||
return new TaskChangeComputer(logsFinder as never, boundaryParser as never);
|
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', () => {
|
describe('TaskChangeComputer', () => {
|
||||||
let tmpDir: string | null = null;
|
let tmpDir: string | null = null;
|
||||||
|
|
||||||
|
|
@ -149,6 +202,108 @@ describe('TaskChangeComputer', () => {
|
||||||
expect(result.warnings).toEqual([]);
|
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 () => {
|
it('warns when completed tasks have no logs even when request status is stale', async () => {
|
||||||
const computer = createNoLogTaskChangeComputer();
|
const computer = createNoLogTaskChangeComputer();
|
||||||
|
|
||||||
|
|
@ -222,7 +377,9 @@ describe('TaskChangeComputer', () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(first.files.map((file) => file.relativePath)).toEqual(['src/a.ts']);
|
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.files).toEqual(first.files);
|
||||||
|
expect(second.warnings).toEqual([NO_TASK_BOUNDARIES_WARNING]);
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
await writeJsonl(logPath, [
|
await writeJsonl(logPath, [
|
||||||
|
|
@ -236,6 +393,7 @@ describe('TaskChangeComputer', () => {
|
||||||
.map((file) => file.relativePath)
|
.map((file) => file.relativePath)
|
||||||
.sort((left, right) => left.localeCompare(right))
|
.sort((left, right) => left.localeCompare(right))
|
||||||
).toEqual(['src/a.ts', 'src/b.ts']);
|
).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 () => {
|
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;
|
] as const;
|
||||||
|
|
||||||
type WorkspaceTrustTestEnvName = (typeof WORKSPACE_TRUST_TEST_ENV_NAMES)[number];
|
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', () => {
|
describe('Team agent launch matrix safe e2e', () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
|
|
@ -4030,8 +4043,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
(svc as any).readProcessRssBytesByPid = async () =>
|
(svc as any).readProcessUsageStatsByPid = async () =>
|
||||||
new Map([[sharedHostPid, 183.9 * 1024 * 1024]]);
|
createRuntimeUsageStatsMap([[sharedHostPid, 183.9 * 1024 * 1024]]);
|
||||||
|
|
||||||
await waitForCondition(async () => {
|
await waitForCondition(async () => {
|
||||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
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);
|
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);
|
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);
|
const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||||
|
|
||||||
|
|
@ -4441,8 +4457,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
(restartedService as any).readProcessRssBytesByPid = async () =>
|
(restartedService as any).readProcessUsageStatsByPid = async () =>
|
||||||
new Map([[sharedHostPid, 188.4 * 1024 * 1024]]);
|
createRuntimeUsageStatsMap([[sharedHostPid, 188.4 * 1024 * 1024]]);
|
||||||
|
|
||||||
const runtimeSnapshot = await restartedService.getTeamAgentRuntimeSnapshot(teamName);
|
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);
|
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' }],
|
['alice', { alive: true, pid: 64102, model: 'haiku-stale' }],
|
||||||
['bob', { alive: true, pid: 64103, model: 'sonnet-stale' }],
|
['bob', { alive: true, pid: 64103, model: 'sonnet-stale' }],
|
||||||
]);
|
]);
|
||||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
|
||||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
createRuntimeUsageStatsByPid(pids);
|
||||||
|
|
||||||
const staleSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
const staleSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||||
expect(staleSnapshot).toMatchObject({
|
expect(staleSnapshot).toMatchObject({
|
||||||
|
|
@ -13645,8 +13662,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
['alice', { alive: true, pid: 64502, model: 'haiku-before-stop' }],
|
['alice', { alive: true, pid: 64502, model: 'haiku-before-stop' }],
|
||||||
['bob', { alive: true, pid: 64503, model: 'sonnet-before-stop' }],
|
['bob', { alive: true, pid: 64503, model: 'sonnet-before-stop' }],
|
||||||
]);
|
]);
|
||||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
|
||||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
createRuntimeUsageStatsByPid(pids);
|
||||||
|
|
||||||
const beforeStop = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
const beforeStop = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||||
expect(beforeStop).toMatchObject({
|
expect(beforeStop).toMatchObject({
|
||||||
|
|
@ -13772,8 +13789,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
|
||||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
createRuntimeUsageStatsByPid(pids);
|
||||||
|
|
||||||
const afterSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
const afterSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||||
expect(afterSwitch).toMatchObject({
|
expect(afterSwitch).toMatchObject({
|
||||||
|
|
@ -13822,8 +13839,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
|
||||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
createRuntimeUsageStatsByPid(pids);
|
||||||
|
|
||||||
const afterSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
const afterSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||||
expect(afterSwitch).toMatchObject({
|
expect(afterSwitch).toMatchObject({
|
||||||
|
|
@ -13863,8 +13880,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
|
||||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
createRuntimeUsageStatsByPid(pids);
|
||||||
|
|
||||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||||
expect(snapshot.members.alice).toMatchObject({
|
expect(snapshot.members.alice).toMatchObject({
|
||||||
|
|
@ -13901,8 +13918,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
|
||||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
createRuntimeUsageStatsByPid(pids);
|
||||||
|
|
||||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||||
expect(snapshot.members.alice).toMatchObject({
|
expect(snapshot.members.alice).toMatchObject({
|
||||||
|
|
@ -13937,8 +13954,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
|
||||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
createRuntimeUsageStatsByPid(pids);
|
||||||
|
|
||||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||||
expect(snapshot.members.bob).toMatchObject({
|
expect(snapshot.members.bob).toMatchObject({
|
||||||
|
|
@ -13973,8 +13990,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
|
||||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
createRuntimeUsageStatsByPid(pids);
|
||||||
|
|
||||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||||
expect(snapshot.members.bob).toMatchObject({
|
expect(snapshot.members.bob).toMatchObject({
|
||||||
|
|
@ -14018,8 +14035,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
|
||||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
createRuntimeUsageStatsByPid(pids);
|
||||||
|
|
||||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||||
expect(snapshot.members.reviewer).toMatchObject({
|
expect(snapshot.members.reviewer).toMatchObject({
|
||||||
|
|
@ -14063,8 +14080,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
|
||||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
createRuntimeUsageStatsByPid(pids);
|
||||||
|
|
||||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||||
expect(snapshot.members.reviewer).toMatchObject({
|
expect(snapshot.members.reviewer).toMatchObject({
|
||||||
|
|
@ -14199,8 +14216,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
|
||||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
createRuntimeUsageStatsByPid(pids);
|
||||||
|
|
||||||
const afterSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
const afterSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||||
expect(afterSwitch).toMatchObject({
|
expect(afterSwitch).toMatchObject({
|
||||||
|
|
@ -14249,8 +14266,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
|
||||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
createRuntimeUsageStatsByPid(pids);
|
||||||
|
|
||||||
const afterSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
const afterSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||||
expect(afterSwitch).toMatchObject({
|
expect(afterSwitch).toMatchObject({
|
||||||
|
|
@ -14293,8 +14310,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
|
||||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
createRuntimeUsageStatsByPid(pids);
|
||||||
|
|
||||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||||
expect(snapshot).toMatchObject({
|
expect(snapshot).toMatchObject({
|
||||||
|
|
@ -14338,8 +14355,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
['bob', { alive: true, pid: 64704, model: 'opencode/minimax-stale' }],
|
['bob', { alive: true, pid: 64704, model: 'opencode/minimax-stale' }],
|
||||||
['tom', { alive: true, pid: 64705, model: 'opencode/nemotron-stale' }],
|
['tom', { alive: true, pid: 64705, model: 'opencode/nemotron-stale' }],
|
||||||
]);
|
]);
|
||||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
|
||||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
createRuntimeUsageStatsByPid(pids);
|
||||||
|
|
||||||
const staleSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
const staleSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||||
expect(staleSnapshot).toMatchObject({
|
expect(staleSnapshot).toMatchObject({
|
||||||
|
|
@ -14463,8 +14480,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
['bob', { alive: true, pid: 50203, model: 'sonnet-runtime' }],
|
['bob', { alive: true, pid: 50203, model: 'sonnet-runtime' }],
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
|
||||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
createRuntimeUsageStatsByPid(pids);
|
||||||
|
|
||||||
const firstSnapshot = await svc.getTeamAgentRuntimeSnapshot(firstTeamName);
|
const firstSnapshot = await svc.getTeamAgentRuntimeSnapshot(firstTeamName);
|
||||||
const secondSnapshot = await svc.getTeamAgentRuntimeSnapshot(secondTeamName);
|
const secondSnapshot = await svc.getTeamAgentRuntimeSnapshot(secondTeamName);
|
||||||
|
|
@ -14543,8 +14560,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
(svc as any).readProcessUsageStatsByPid = async (pids: number[]) =>
|
||||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
createRuntimeUsageStatsByPid(pids);
|
||||||
|
|
||||||
const beforeStop = await svc.getTeamAgentRuntimeSnapshot(stoppedTeamName);
|
const beforeStop = await svc.getTeamAgentRuntimeSnapshot(stoppedTeamName);
|
||||||
expect(beforeStop.members['team-lead']).toMatchObject({
|
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 {
|
return {
|
||||||
cpu: 0,
|
cpu,
|
||||||
memory,
|
memory,
|
||||||
ppid: 1,
|
ppid: 1,
|
||||||
pid,
|
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 () => {
|
it('does not send legacy process backend pane markers to tmux liveness lookup', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
|
|
@ -2594,7 +2686,10 @@ describe('TeamProvisioningService', () => {
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
(svc as any).teamMetaStore = {
|
(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');
|
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 () => {
|
it('shows a worktree badge only for teammates configured with worktree isolation', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
|
|
|
||||||
|
|
@ -4274,6 +4274,55 @@ describe('teamSlice actions', () => {
|
||||||
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(nextSnapshot);
|
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 () => {
|
it('updates runtime snapshots when copy-diagnostics details change', async () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
const snapshot = createRuntimeSnapshot({
|
const snapshot = createRuntimeSnapshot({
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,15 @@ const sentryNoOp = {
|
||||||
init: vi.fn(),
|
init: vi.fn(),
|
||||||
addBreadcrumb: vi.fn(),
|
addBreadcrumb: vi.fn(),
|
||||||
captureException: vi.fn(),
|
captureException: vi.fn(),
|
||||||
|
setUser: vi.fn(),
|
||||||
|
setTags: vi.fn(),
|
||||||
startSpan: vi.fn((_opts: unknown, fn: () => unknown) => fn()),
|
startSpan: vi.fn((_opts: unknown, fn: () => unknown) => fn()),
|
||||||
withScope: vi.fn((fn: (scope: unknown) => void) => fn({ setContext: vi.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/main', () => sentryNoOp);
|
||||||
vi.mock('@sentry/electron/renderer', () => sentryNoOp);
|
vi.mock('@sentry/electron/renderer', () => sentryNoOp);
|
||||||
|
|
@ -47,8 +53,8 @@ function formatConsoleCall(args: unknown[]): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue