fix(team): harden launch reconciliation and diagnostics
This commit is contained in:
parent
a0170aed39
commit
601d51d54e
11 changed files with 1316 additions and 55 deletions
|
|
@ -260,6 +260,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
private mutationQueue: Promise<void> = Promise.resolve();
|
private mutationQueue: Promise<void> = Promise.resolve();
|
||||||
private mutationQueueRelease: (() => void) | null = null;
|
private mutationQueueRelease: (() => void) | null = null;
|
||||||
private activeMutationCount = 0;
|
private activeMutationCount = 0;
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly logger: LoggerPort,
|
private readonly logger: LoggerPort,
|
||||||
|
|
@ -290,8 +291,12 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSnapshot(): Promise<CodexAccountSnapshotDto> {
|
async getSnapshot(): Promise<CodexAccountSnapshotDto> {
|
||||||
if (this.snapshotCache && Date.now() - this.snapshotObservedAt <= SNAPSHOT_CACHE_TTL_MS) {
|
const cached = this.getCachedSnapshotForOptions({
|
||||||
return deepClone(this.snapshotCache);
|
includeRateLimits: false,
|
||||||
|
forceRefreshToken: false,
|
||||||
|
});
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.refreshSnapshot();
|
return this.refreshSnapshot();
|
||||||
|
|
@ -301,10 +306,13 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
includeRateLimits?: boolean;
|
includeRateLimits?: boolean;
|
||||||
forceRefreshToken?: boolean;
|
forceRefreshToken?: boolean;
|
||||||
}): Promise<CodexAccountSnapshotDto> {
|
}): Promise<CodexAccountSnapshotDto> {
|
||||||
this.pendingRefreshOptions = mergeRefreshOptions(
|
const normalizedOptions = normalizeRefreshOptions(options);
|
||||||
this.pendingRefreshOptions,
|
const cached = this.getCachedSnapshotForOptions(normalizedOptions);
|
||||||
normalizeRefreshOptions(options)
|
if (cached) {
|
||||||
);
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingRefreshOptions = mergeRefreshOptions(this.pendingRefreshOptions, normalizedOptions);
|
||||||
|
|
||||||
if (!this.refreshPromise) {
|
if (!this.refreshPromise) {
|
||||||
this.refreshPromise = this.drainRefreshQueue().finally(() => {
|
this.refreshPromise = this.drainRefreshQueue().finally(() => {
|
||||||
|
|
@ -386,6 +394,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
}
|
}
|
||||||
|
|
||||||
async dispose(): Promise<void> {
|
async dispose(): Promise<void> {
|
||||||
|
this.disposed = true;
|
||||||
await this.loginSessionManager.dispose();
|
await this.loginSessionManager.dispose();
|
||||||
this.listeners.clear();
|
this.listeners.clear();
|
||||||
this.snapshotCache = null;
|
this.snapshotCache = null;
|
||||||
|
|
@ -410,7 +419,8 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
this.pendingRefreshOptions = null;
|
this.pendingRefreshOptions = null;
|
||||||
await this.mutationQueue.catch(() => undefined);
|
await this.mutationQueue.catch(() => undefined);
|
||||||
|
|
||||||
lastSnapshot = await this.loadSnapshot(nextOptions);
|
lastSnapshot =
|
||||||
|
this.getCachedSnapshotForOptions(nextOptions) ?? (await this.loadSnapshot(nextOptions));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!lastSnapshot) {
|
if (!lastSnapshot) {
|
||||||
|
|
@ -464,12 +474,17 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
let accountPayload = this.lastKnownAccount?.payload ?? null;
|
let accountPayload = this.lastKnownAccount?.payload ?? null;
|
||||||
let requiresOpenaiAuth: boolean | null = accountPayload?.requiresOpenaiAuth ?? null;
|
let requiresOpenaiAuth: boolean | null = accountPayload?.requiresOpenaiAuth ?? null;
|
||||||
let runtimeContext = createRuntimeContext(binaryPath, null);
|
let runtimeContext = createRuntimeContext(binaryPath, null);
|
||||||
|
const cachedRateLimitsAreFresh = this.hasFreshRateLimits(now);
|
||||||
|
const shouldRequestRateLimits =
|
||||||
|
options?.includeRateLimits === true && !cachedRateLimitsAreFresh;
|
||||||
|
let rateLimitsReadFailure: unknown | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const accountResult = await this.appServerClient.readAccount({
|
const accountResult = await this.appServerClient.readAccountSnapshot({
|
||||||
binaryPath,
|
binaryPath,
|
||||||
env,
|
env,
|
||||||
refreshToken: options?.forceRefreshToken ?? false,
|
refreshToken: options?.forceRefreshToken ?? false,
|
||||||
|
includeRateLimits: shouldRequestRateLimits,
|
||||||
});
|
});
|
||||||
runtimeContext = createRuntimeContext(binaryPath, accountResult.initialize.codexHome);
|
runtimeContext = createRuntimeContext(binaryPath, accountResult.initialize.codexHome);
|
||||||
if (runtimeContext.codexHome) {
|
if (runtimeContext.codexHome) {
|
||||||
|
|
@ -498,6 +513,14 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
observedAt: now,
|
observedAt: now,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (accountResult.rateLimits?.ok) {
|
||||||
|
this.lastKnownRateLimits = {
|
||||||
|
payload: accountResult.rateLimits.payload,
|
||||||
|
observedAt: now,
|
||||||
|
};
|
||||||
|
} else if (accountResult.rateLimits) {
|
||||||
|
rateLimitsReadFailure = accountResult.rateLimits.error;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const failure = classifyAppServerFailure(error);
|
const failure = classifyAppServerFailure(error);
|
||||||
appServerState = failure.appServerState;
|
appServerState = failure.appServerState;
|
||||||
|
|
@ -525,35 +548,18 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
|
|
||||||
let rateLimits: CodexRateLimitSnapshotDto | null = null;
|
let rateLimits: CodexRateLimitSnapshotDto | null = null;
|
||||||
const shouldLoadRateLimits =
|
const shouldLoadRateLimits =
|
||||||
options?.includeRateLimits === true ||
|
options?.includeRateLimits === true || this.hasFreshRateLimits(now);
|
||||||
(this.lastKnownRateLimits !== null &&
|
|
||||||
now - this.lastKnownRateLimits.observedAt <= RATE_LIMITS_CACHE_TTL_MS);
|
|
||||||
|
|
||||||
if (shouldLoadRateLimits) {
|
if (shouldLoadRateLimits) {
|
||||||
try {
|
if (this.hasFreshRateLimits(now) && this.lastKnownRateLimits) {
|
||||||
if (
|
rateLimits = asRateLimits(this.lastKnownRateLimits.payload.rateLimits);
|
||||||
this.lastKnownRateLimits &&
|
} else if (rateLimitsReadFailure) {
|
||||||
now - this.lastKnownRateLimits.observedAt <= RATE_LIMITS_CACHE_TTL_MS
|
|
||||||
) {
|
|
||||||
rateLimits = asRateLimits(this.lastKnownRateLimits.payload.rateLimits);
|
|
||||||
} else {
|
|
||||||
const rateLimitsPayload = await this.appServerClient.readRateLimits({
|
|
||||||
binaryPath,
|
|
||||||
env,
|
|
||||||
});
|
|
||||||
this.lastKnownRateLimits = {
|
|
||||||
payload: rateLimitsPayload,
|
|
||||||
observedAt: now,
|
|
||||||
};
|
|
||||||
rateLimits = asRateLimits(rateLimitsPayload.rateLimits);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn('codex account rate limits refresh failed', {
|
this.logger.warn('codex account rate limits refresh failed', {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error:
|
||||||
|
rateLimitsReadFailure instanceof Error
|
||||||
|
? rateLimitsReadFailure.message
|
||||||
|
: String(rateLimitsReadFailure),
|
||||||
});
|
});
|
||||||
rateLimits = this.lastKnownRateLimits
|
|
||||||
? asRateLimits(this.lastKnownRateLimits.payload.rateLimits)
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -590,6 +596,10 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
}
|
}
|
||||||
|
|
||||||
private setSnapshot(nextSnapshot: CodexAccountSnapshotDto): CodexAccountSnapshotDto {
|
private setSnapshot(nextSnapshot: CodexAccountSnapshotDto): CodexAccountSnapshotDto {
|
||||||
|
if (this.disposed) {
|
||||||
|
return deepClone(nextSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
this.snapshotCache = deepClone(nextSnapshot);
|
this.snapshotCache = deepClone(nextSnapshot);
|
||||||
this.snapshotObservedAt = Date.now();
|
this.snapshotObservedAt = Date.now();
|
||||||
const snapshot = deepClone(nextSnapshot);
|
const snapshot = deepClone(nextSnapshot);
|
||||||
|
|
@ -600,6 +610,36 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getCachedSnapshotForOptions(
|
||||||
|
options: CodexSnapshotRefreshOptions
|
||||||
|
): CodexAccountSnapshotDto | null {
|
||||||
|
if (
|
||||||
|
this.hasPendingMutation() ||
|
||||||
|
options.forceRefreshToken ||
|
||||||
|
!this.snapshotCache ||
|
||||||
|
Date.now() - this.snapshotObservedAt > SNAPSHOT_CACHE_TTL_MS
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.includeRateLimits && !this.hasFreshRateLimits(Date.now())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return deepClone(this.snapshotCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasPendingMutation(): boolean {
|
||||||
|
return this.activeMutationCount > 0 || this.mutationQueueRelease !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasFreshRateLimits(now: number): boolean {
|
||||||
|
return (
|
||||||
|
this.lastKnownRateLimits !== null &&
|
||||||
|
now - this.lastKnownRateLimits.observedAt <= RATE_LIMITS_CACHE_TTL_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async emitCurrentSnapshot(): Promise<CodexAccountSnapshotDto> {
|
private async emitCurrentSnapshot(): Promise<CodexAccountSnapshotDto> {
|
||||||
if (!this.snapshotCache) {
|
if (!this.snapshotCache) {
|
||||||
return this.refreshSnapshot();
|
return this.refreshSnapshot();
|
||||||
|
|
|
||||||
|
|
@ -12,26 +12,39 @@ const ACCOUNT_RATE_LIMITS_TIMEOUT_MS = 4_500;
|
||||||
const ACCOUNT_LOGOUT_TIMEOUT_MS = 3_500;
|
const ACCOUNT_LOGOUT_TIMEOUT_MS = 3_500;
|
||||||
const INITIALIZE_TIMEOUT_MS = 6_000;
|
const INITIALIZE_TIMEOUT_MS = 6_000;
|
||||||
const TOTAL_TIMEOUT_MS = 9_000;
|
const TOTAL_TIMEOUT_MS = 9_000;
|
||||||
|
const TOTAL_WITH_RATE_LIMITS_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
type CodexAccountRateLimitsReadResult =
|
||||||
|
| { ok: true; payload: CodexAppServerGetAccountRateLimitsResponse }
|
||||||
|
| { ok: false; error: unknown };
|
||||||
|
|
||||||
export class CodexAccountAppServerClient {
|
export class CodexAccountAppServerClient {
|
||||||
constructor(private readonly sessionFactory: CodexAppServerSessionFactory) {}
|
constructor(private readonly sessionFactory: CodexAppServerSessionFactory) {}
|
||||||
|
|
||||||
async readAccount(options: {
|
async readAccountSnapshot(options: {
|
||||||
binaryPath: string;
|
binaryPath: string;
|
||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
refreshToken?: boolean;
|
refreshToken?: boolean;
|
||||||
|
includeRateLimits?: boolean;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
account: CodexAppServerGetAccountResponse;
|
account: CodexAppServerGetAccountResponse;
|
||||||
|
rateLimits: CodexAccountRateLimitsReadResult | null;
|
||||||
initialize: { codexHome: string; platformFamily: string; platformOs: string };
|
initialize: { codexHome: string; platformFamily: string; platformOs: string };
|
||||||
}> {
|
}> {
|
||||||
|
const includeRateLimits = options.includeRateLimits === true;
|
||||||
|
|
||||||
return this.sessionFactory.withSession(
|
return this.sessionFactory.withSession(
|
||||||
{
|
{
|
||||||
binaryPath: options.binaryPath,
|
binaryPath: options.binaryPath,
|
||||||
env: options.env,
|
env: options.env,
|
||||||
requestTimeoutMs: ACCOUNT_READ_TIMEOUT_MS,
|
requestTimeoutMs: includeRateLimits
|
||||||
|
? ACCOUNT_RATE_LIMITS_TIMEOUT_MS
|
||||||
|
: ACCOUNT_READ_TIMEOUT_MS,
|
||||||
initializeTimeoutMs: INITIALIZE_TIMEOUT_MS,
|
initializeTimeoutMs: INITIALIZE_TIMEOUT_MS,
|
||||||
totalTimeoutMs: TOTAL_TIMEOUT_MS,
|
totalTimeoutMs: includeRateLimits ? TOTAL_WITH_RATE_LIMITS_TIMEOUT_MS : TOTAL_TIMEOUT_MS,
|
||||||
label: 'codex app-server account/read',
|
label: includeRateLimits
|
||||||
|
? 'codex app-server account/read with rateLimits/read'
|
||||||
|
: 'codex app-server account/read',
|
||||||
},
|
},
|
||||||
async (session) => {
|
async (session) => {
|
||||||
const account = await session.request<CodexAppServerGetAccountResponse>(
|
const account = await session.request<CodexAppServerGetAccountResponse>(
|
||||||
|
|
@ -42,8 +55,25 @@ export class CodexAccountAppServerClient {
|
||||||
ACCOUNT_READ_TIMEOUT_MS
|
ACCOUNT_READ_TIMEOUT_MS
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let rateLimits: CodexAccountRateLimitsReadResult | null = null;
|
||||||
|
if (includeRateLimits) {
|
||||||
|
try {
|
||||||
|
rateLimits = {
|
||||||
|
ok: true,
|
||||||
|
payload: await session.request<CodexAppServerGetAccountRateLimitsResponse>(
|
||||||
|
'account/rateLimits/read',
|
||||||
|
undefined,
|
||||||
|
ACCOUNT_RATE_LIMITS_TIMEOUT_MS
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
rateLimits = { ok: false, error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
account,
|
account,
|
||||||
|
rateLimits,
|
||||||
initialize: {
|
initialize: {
|
||||||
codexHome: session.initializeResponse.codexHome,
|
codexHome: session.initializeResponse.codexHome,
|
||||||
platformFamily: session.initializeResponse.platformFamily,
|
platformFamily: session.initializeResponse.platformFamily,
|
||||||
|
|
@ -54,6 +84,21 @@ export class CodexAccountAppServerClient {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async readAccount(options: {
|
||||||
|
binaryPath: string;
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
refreshToken?: boolean;
|
||||||
|
}): Promise<{
|
||||||
|
account: CodexAppServerGetAccountResponse;
|
||||||
|
initialize: { codexHome: string; platformFamily: string; platformOs: string };
|
||||||
|
}> {
|
||||||
|
const result = await this.readAccountSnapshot(options);
|
||||||
|
return {
|
||||||
|
account: result.account,
|
||||||
|
initialize: result.initialize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async readRateLimits(options: {
|
async readRateLimits(options: {
|
||||||
binaryPath: string;
|
binaryPath: string;
|
||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { CodexAccountAppServerClient } from '../CodexAccountAppServerClient';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CodexAppServerSession,
|
||||||
|
CodexAppServerSessionFactory,
|
||||||
|
} from '@main/services/infrastructure/codexAppServer';
|
||||||
|
|
||||||
|
function createFactory(request: CodexAppServerSession['request']): {
|
||||||
|
factory: CodexAppServerSessionFactory;
|
||||||
|
requests: { method: string; params: unknown }[];
|
||||||
|
} {
|
||||||
|
const requests: { method: string; params: unknown }[] = [];
|
||||||
|
const session: CodexAppServerSession = {
|
||||||
|
initializeResponse: {
|
||||||
|
userAgent: 'codex-cli 0.117.0',
|
||||||
|
codexHome: '/Users/me/.codex',
|
||||||
|
platformFamily: 'macos',
|
||||||
|
platformOs: 'darwin',
|
||||||
|
},
|
||||||
|
request: async <TResult>(method: string, params?: unknown, timeoutMs?: number) => {
|
||||||
|
requests.push({ method, params });
|
||||||
|
return request<TResult>(method, params, timeoutMs);
|
||||||
|
},
|
||||||
|
notify: async () => undefined,
|
||||||
|
onNotification: () => () => undefined,
|
||||||
|
close: async () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const factory = {
|
||||||
|
withSession: async <TResult>(
|
||||||
|
_options: unknown,
|
||||||
|
handler: (session: CodexAppServerSession) => Promise<TResult>
|
||||||
|
): Promise<TResult> => handler(session),
|
||||||
|
} as unknown as CodexAppServerSessionFactory;
|
||||||
|
|
||||||
|
return { factory, requests };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CodexAccountAppServerClient', () => {
|
||||||
|
it('reads account and optional rate limits in one app-server session', async () => {
|
||||||
|
let sessionCount = 0;
|
||||||
|
const { factory, requests } = createFactory(async <TResult>(method: string) => {
|
||||||
|
if (method === 'account/read') {
|
||||||
|
return {
|
||||||
|
account: { type: 'chatgpt', email: 'user@example.com', planType: 'pro' },
|
||||||
|
requiresOpenaiAuth: true,
|
||||||
|
} as TResult;
|
||||||
|
}
|
||||||
|
if (method === 'account/rateLimits/read') {
|
||||||
|
return {
|
||||||
|
rateLimits: {
|
||||||
|
limitId: 'codex',
|
||||||
|
limitName: null,
|
||||||
|
primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: null },
|
||||||
|
secondary: null,
|
||||||
|
credits: null,
|
||||||
|
planType: 'pro',
|
||||||
|
},
|
||||||
|
rateLimitsByLimitId: null,
|
||||||
|
} as TResult;
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected method ${method}`);
|
||||||
|
});
|
||||||
|
const countedFactory = {
|
||||||
|
withSession: async <TResult>(
|
||||||
|
options: unknown,
|
||||||
|
handler: (session: CodexAppServerSession) => Promise<TResult>
|
||||||
|
): Promise<TResult> => {
|
||||||
|
sessionCount += 1;
|
||||||
|
return factory.withSession(options as never, handler);
|
||||||
|
},
|
||||||
|
} as unknown as CodexAppServerSessionFactory;
|
||||||
|
|
||||||
|
const client = new CodexAccountAppServerClient(countedFactory);
|
||||||
|
const result = await client.readAccountSnapshot({
|
||||||
|
binaryPath: '/usr/local/bin/codex',
|
||||||
|
env: {},
|
||||||
|
refreshToken: true,
|
||||||
|
includeRateLimits: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sessionCount).toBe(1);
|
||||||
|
expect(result.account.account).toMatchObject({
|
||||||
|
type: 'chatgpt',
|
||||||
|
email: 'user@example.com',
|
||||||
|
});
|
||||||
|
expect(result.rateLimits).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
payload: {
|
||||||
|
rateLimits: {
|
||||||
|
primary: { usedPercent: 42 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.initialize.codexHome).toBe('/Users/me/.codex');
|
||||||
|
expect(requests).toEqual([
|
||||||
|
{ method: 'account/read', params: { refreshToken: true } },
|
||||||
|
{ method: 'account/rateLimits/read', params: undefined },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a successful account read when optional rate limits fail', async () => {
|
||||||
|
const rateLimitError = new Error('rate limits failed');
|
||||||
|
const { factory } = createFactory(async <TResult>(method: string) => {
|
||||||
|
if (method === 'account/read') {
|
||||||
|
return {
|
||||||
|
account: { type: 'apiKey' },
|
||||||
|
requiresOpenaiAuth: false,
|
||||||
|
} as TResult;
|
||||||
|
}
|
||||||
|
if (method === 'account/rateLimits/read') {
|
||||||
|
throw rateLimitError;
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected method ${method}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new CodexAccountAppServerClient(factory);
|
||||||
|
const result = await client.readAccountSnapshot({
|
||||||
|
binaryPath: '/usr/local/bin/codex',
|
||||||
|
env: {},
|
||||||
|
includeRateLimits: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.account).toEqual({
|
||||||
|
account: { type: 'apiKey' },
|
||||||
|
requiresOpenaiAuth: false,
|
||||||
|
});
|
||||||
|
expect(result.rateLimits).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: rateLimitError,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces account read failures without attempting rate limits', async () => {
|
||||||
|
const requests: string[] = [];
|
||||||
|
const { factory } = createFactory(async <TResult>(method: string) => {
|
||||||
|
requests.push(method);
|
||||||
|
if (method === 'account/read') {
|
||||||
|
throw new Error('account failed');
|
||||||
|
}
|
||||||
|
return {} as TResult;
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new CodexAccountAppServerClient(factory);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.readAccountSnapshot({
|
||||||
|
binaryPath: '/usr/local/bin/codex',
|
||||||
|
env: {},
|
||||||
|
includeRateLimits: true,
|
||||||
|
})
|
||||||
|
).rejects.toThrow('account failed');
|
||||||
|
expect(requests).toEqual(['account/read']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -100,6 +100,35 @@ type LogCandidate =
|
||||||
fileName: string;
|
fileName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface MemberLogFileRef {
|
||||||
|
memberName: string;
|
||||||
|
sessionId: string;
|
||||||
|
filePath: string;
|
||||||
|
mtimeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapLimit<T, R>(
|
||||||
|
items: readonly T[],
|
||||||
|
limit: number,
|
||||||
|
fn: (item: T, index: number) => Promise<R>
|
||||||
|
): Promise<R[]> {
|
||||||
|
const results = new Array<R>(items.length);
|
||||||
|
let index = 0;
|
||||||
|
const workerCount = Math.max(1, Math.min(limit, items.length));
|
||||||
|
const workers = new Array(workerCount).fill(0).map(async () => {
|
||||||
|
while (true) {
|
||||||
|
const currentIndex = index;
|
||||||
|
index += 1;
|
||||||
|
if (currentIndex >= items.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
results[currentIndex] = await fn(items[currentIndex], currentIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
export class TeamMemberLogsFinder {
|
export class TeamMemberLogsFinder {
|
||||||
private readonly fileMentionsCache = new Map<string, boolean>();
|
private readonly fileMentionsCache = new Map<string, boolean>();
|
||||||
private readonly attributionCache = new Map<
|
private readonly attributionCache = new Map<
|
||||||
|
|
@ -849,6 +878,116 @@ export class TeamMemberLogsFinder {
|
||||||
return this.listAttributedMemberFiles(teamName);
|
return this.listAttributedMemberFiles(teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findRecentMemberLogFileRefsByMember(
|
||||||
|
teamName: string,
|
||||||
|
memberNames: readonly string[],
|
||||||
|
mtimeSinceMs?: number | null
|
||||||
|
): Promise<MemberLogFileRef[]> {
|
||||||
|
const requestedMembersByKey = new Map<string, string>();
|
||||||
|
for (const memberName of memberNames) {
|
||||||
|
const trimmed = memberName.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = trimmed.toLowerCase();
|
||||||
|
if (!requestedMembersByKey.has(key)) {
|
||||||
|
requestedMembersByKey.set(key, trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (requestedMembersByKey.size === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const discovery = await this.discoverProjectSessions(teamName);
|
||||||
|
if (!discovery) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { projectDir, sessionIds, knownMembers, config } = discovery;
|
||||||
|
const refs: MemberLogFileRef[] = [];
|
||||||
|
const seenFilePaths = new Set<string>();
|
||||||
|
const pushRef = (ref: MemberLogFileRef): void => {
|
||||||
|
if (seenFilePaths.has(ref.filePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seenFilePaths.add(ref.filePath);
|
||||||
|
refs.push(ref);
|
||||||
|
};
|
||||||
|
|
||||||
|
const leadMemberName =
|
||||||
|
config.members?.find((member) => isLeadMemberCheck(member))?.name?.trim() || 'team-lead';
|
||||||
|
const leadKey = leadMemberName.toLowerCase();
|
||||||
|
if (requestedMembersByKey.has(leadKey) && config.leadSessionId) {
|
||||||
|
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(leadJsonl);
|
||||||
|
if (stat.isFile()) {
|
||||||
|
pushRef({
|
||||||
|
memberName: requestedMembersByKey.get(leadKey) ?? leadMemberName,
|
||||||
|
sessionId: config.leadSessionId,
|
||||||
|
filePath: leadJsonl,
|
||||||
|
mtimeMs: stat.mtimeMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// missing lead transcript
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = await this.collectLogCandidates(projectDir, sessionIds, config);
|
||||||
|
const settled = await mapLimit(candidates, SCAN_CONCURRENCY, async (candidate) => {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(candidate.filePath);
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (mtimeSinceMs != null && stat.mtimeMs < mtimeSinceMs) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const attribution =
|
||||||
|
candidate.kind === 'subagent'
|
||||||
|
? await this.getCachedSubagentAttribution(
|
||||||
|
candidate.filePath,
|
||||||
|
knownMembers,
|
||||||
|
stat.mtimeMs
|
||||||
|
)
|
||||||
|
: await this.getCachedMemberSessionAttribution(
|
||||||
|
candidate.filePath,
|
||||||
|
teamName,
|
||||||
|
knownMembers,
|
||||||
|
stat.mtimeMs
|
||||||
|
);
|
||||||
|
if (!attribution) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const memberKey = attribution.detectedMember.trim().toLowerCase();
|
||||||
|
const requestedMemberName = requestedMembersByKey.get(memberKey);
|
||||||
|
if (!requestedMemberName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
memberName: requestedMemberName,
|
||||||
|
sessionId: candidate.sessionId,
|
||||||
|
filePath: candidate.filePath,
|
||||||
|
mtimeMs: stat.mtimeMs,
|
||||||
|
} satisfies MemberLogFileRef;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const ref of settled) {
|
||||||
|
if (ref) {
|
||||||
|
pushRef(ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return refs.sort((left, right) => {
|
||||||
|
const byTime = right.mtimeMs - left.mtimeMs;
|
||||||
|
return byTime !== 0 ? byTime : left.filePath.localeCompare(right.filePath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fast marker probe for task-related logs.
|
* Fast marker probe for task-related logs.
|
||||||
* Prefer structured MCP/TaskUpdate markers for modern sessions.
|
* Prefer structured MCP/TaskUpdate markers for modern sessions.
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,26 @@ import * as fs from 'fs/promises';
|
||||||
|
|
||||||
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||||
|
|
||||||
import type { MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types';
|
import type { MemberLogSummary, MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
|
interface RuntimeAdvisoryLogFileRef {
|
||||||
|
memberName: string;
|
||||||
|
filePath: string;
|
||||||
|
mtimeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimeAdvisoryLogsFinder {
|
||||||
|
findMemberLogs(
|
||||||
|
teamName: string,
|
||||||
|
memberName: string,
|
||||||
|
mtimeSinceMs?: number | null
|
||||||
|
): Promise<Pick<MemberLogSummary, 'filePath'>[]>;
|
||||||
|
findRecentMemberLogFileRefsByMember?(
|
||||||
|
teamName: string,
|
||||||
|
memberNames: readonly string[],
|
||||||
|
mtimeSinceMs?: number | null
|
||||||
|
): Promise<RuntimeAdvisoryLogFileRef[]>;
|
||||||
|
}
|
||||||
|
|
||||||
const LOOKBACK_MS = 10 * 60 * 1000;
|
const LOOKBACK_MS = 10 * 60 * 1000;
|
||||||
const CACHE_TTL_MS = 30_000;
|
const CACHE_TTL_MS = 30_000;
|
||||||
|
|
@ -125,7 +144,9 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
Promise<Map<string, MemberRuntimeAdvisory>>
|
Promise<Map<string, MemberRuntimeAdvisory>>
|
||||||
>();
|
>();
|
||||||
|
|
||||||
constructor(private readonly logsFinder: TeamMemberLogsFinder = new TeamMemberLogsFinder()) {}
|
constructor(
|
||||||
|
private readonly logsFinder: RuntimeAdvisoryLogsFinder = new TeamMemberLogsFinder()
|
||||||
|
) {}
|
||||||
|
|
||||||
async getMemberAdvisories(
|
async getMemberAdvisories(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
|
|
@ -210,14 +231,7 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (membersToFetch.length > 0) {
|
if (membersToFetch.length > 0) {
|
||||||
const fetched = await mapLimit(
|
const fetched = await this.findRecentMemberAdvisories(teamName, membersToFetch);
|
||||||
membersToFetch,
|
|
||||||
ADVISORY_FETCH_CONCURRENCY,
|
|
||||||
async (memberName) => {
|
|
||||||
const advisory = await this.findRecentMemberAdvisory(teamName, memberName);
|
|
||||||
return [memberName, advisory] as const;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const fetchedAt = Date.now();
|
const fetchedAt = Date.now();
|
||||||
for (const [memberName, advisory] of fetched) {
|
for (const [memberName, advisory] of fetched) {
|
||||||
const normalizedMemberName = this.normalizeToken(memberName);
|
const normalizedMemberName = this.normalizeToken(memberName);
|
||||||
|
|
@ -296,11 +310,83 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
memberName,
|
memberName,
|
||||||
Date.now() - LOOKBACK_MS
|
Date.now() - LOOKBACK_MS
|
||||||
);
|
);
|
||||||
for (const summary of summaries) {
|
return this.findRecentMemberAdvisoryInFiles(
|
||||||
if (!summary.filePath) {
|
summaries.flatMap((summary) => summary.filePath ?? [])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findRecentMemberAdvisories(
|
||||||
|
teamName: string,
|
||||||
|
memberNames: readonly string[]
|
||||||
|
): Promise<readonly (readonly [string, MemberRuntimeAdvisory | null])[]> {
|
||||||
|
if (this.logsFinder.findRecentMemberLogFileRefsByMember) {
|
||||||
|
try {
|
||||||
|
return await this.findRecentMemberAdvisoriesFromBatchRefs(teamName, memberNames);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('batch member runtime advisory log lookup failed; falling back', {
|
||||||
|
teamName,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapLimit(memberNames, ADVISORY_FETCH_CONCURRENCY, async (memberName) => {
|
||||||
|
const advisory = await this.findRecentMemberAdvisory(teamName, memberName);
|
||||||
|
return [memberName, advisory] as const;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findRecentMemberAdvisoriesFromBatchRefs(
|
||||||
|
teamName: string,
|
||||||
|
memberNames: readonly string[]
|
||||||
|
): Promise<readonly (readonly [string, MemberRuntimeAdvisory | null])[]> {
|
||||||
|
const memberNamesByKey = new Map<string, string>();
|
||||||
|
for (const memberName of memberNames) {
|
||||||
|
const normalized = this.normalizeToken(memberName);
|
||||||
|
if (!memberNamesByKey.has(normalized)) {
|
||||||
|
memberNamesByKey.set(normalized, memberName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refs = await this.logsFinder.findRecentMemberLogFileRefsByMember!(
|
||||||
|
teamName,
|
||||||
|
memberNames,
|
||||||
|
Date.now() - LOOKBACK_MS
|
||||||
|
);
|
||||||
|
const refsByMember = new Map<string, RuntimeAdvisoryLogFileRef[]>();
|
||||||
|
for (const ref of refs) {
|
||||||
|
const normalizedMemberName = this.normalizeToken(ref.memberName);
|
||||||
|
if (!memberNamesByKey.has(normalizedMemberName)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const advisory = await this.readRecentApiRetryAdvisory(summary.filePath);
|
const bucket = refsByMember.get(normalizedMemberName) ?? [];
|
||||||
|
bucket.push(ref);
|
||||||
|
refsByMember.set(normalizedMemberName, bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapLimit(memberNames, ADVISORY_FETCH_CONCURRENCY, async (memberName) => {
|
||||||
|
const normalizedMemberName = this.normalizeToken(memberName);
|
||||||
|
const refsForMember = refsByMember.get(normalizedMemberName) ?? [];
|
||||||
|
const seenFilePaths = new Set<string>();
|
||||||
|
const filePaths = refsForMember
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => right.mtimeMs - left.mtimeMs)
|
||||||
|
.flatMap((ref) => {
|
||||||
|
if (!ref.filePath || seenFilePaths.has(ref.filePath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
seenFilePaths.add(ref.filePath);
|
||||||
|
return [ref.filePath];
|
||||||
|
});
|
||||||
|
return [memberName, await this.findRecentMemberAdvisoryInFiles(filePaths)] as const;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findRecentMemberAdvisoryInFiles(
|
||||||
|
filePaths: readonly string[]
|
||||||
|
): Promise<MemberRuntimeAdvisory | null> {
|
||||||
|
for (const filePath of filePaths) {
|
||||||
|
const advisory = await this.readRecentApiRetryAdvisory(filePath);
|
||||||
if (advisory) {
|
if (advisory) {
|
||||||
return advisory;
|
return advisory;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19384,6 +19384,70 @@ export class TeamProvisioningService {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async applyBootstrapTranscriptEvidenceOverlay(
|
||||||
|
snapshot: PersistedTeamLaunchSnapshot | null
|
||||||
|
): Promise<PersistedTeamLaunchSnapshot | null> {
|
||||||
|
if (!snapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const nextMembers: Record<string, PersistedTeamLaunchMemberState> = { ...snapshot.members };
|
||||||
|
for (const expected of this.getPersistedLaunchMemberNames(snapshot)) {
|
||||||
|
const current = nextMembers[expected];
|
||||||
|
if (
|
||||||
|
!current ||
|
||||||
|
current.bootstrapConfirmed ||
|
||||||
|
isPersistedOpenCodeSecondaryLaneMember(current)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptedAtMs =
|
||||||
|
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
|
||||||
|
const transcriptOutcome = await this.findBootstrapTranscriptOutcome(
|
||||||
|
snapshot.teamName,
|
||||||
|
expected,
|
||||||
|
Number.isFinite(acceptedAtMs) ? acceptedAtMs : null
|
||||||
|
);
|
||||||
|
if (transcriptOutcome?.kind !== 'success') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMember: PersistedTeamLaunchMemberState = {
|
||||||
|
...current,
|
||||||
|
agentToolAccepted: true,
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
hardFailure: false,
|
||||||
|
hardFailureReason: undefined,
|
||||||
|
lastHeartbeatAt: current.lastHeartbeatAt ?? transcriptOutcome.observedAt,
|
||||||
|
lastEvaluatedAt: nowIso(),
|
||||||
|
sources: {
|
||||||
|
...(current.sources ?? {}),
|
||||||
|
hardFailureSignal: undefined,
|
||||||
|
},
|
||||||
|
diagnostics: undefined,
|
||||||
|
};
|
||||||
|
nextMember.launchState = deriveMemberLaunchState(nextMember);
|
||||||
|
nextMembers[expected] = nextMember;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPersistedLaunchSnapshot({
|
||||||
|
teamName: snapshot.teamName,
|
||||||
|
expectedMembers: snapshot.expectedMembers,
|
||||||
|
bootstrapExpectedMembers: snapshot.bootstrapExpectedMembers,
|
||||||
|
leadSessionId: snapshot.leadSessionId,
|
||||||
|
launchPhase: snapshot.launchPhase,
|
||||||
|
members: nextMembers,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private needsBootstrapAcceptanceReconcile(
|
private needsBootstrapAcceptanceReconcile(
|
||||||
snapshot: PersistedTeamLaunchSnapshot | null,
|
snapshot: PersistedTeamLaunchSnapshot | null,
|
||||||
bootstrapSnapshot: PersistedTeamLaunchSnapshot | null
|
bootstrapSnapshot: PersistedTeamLaunchSnapshot | null
|
||||||
|
|
@ -19449,11 +19513,13 @@ export class TeamProvisioningService {
|
||||||
const filteredBootstrapSnapshot = bootstrapSnapshot
|
const filteredBootstrapSnapshot = bootstrapSnapshot
|
||||||
? this.filterRemovedMembersFromLaunchSnapshot(bootstrapSnapshot, metaMembers)
|
? this.filterRemovedMembersFromLaunchSnapshot(bootstrapSnapshot, metaMembers)
|
||||||
: null;
|
: null;
|
||||||
|
const overlaidBootstrapSnapshot =
|
||||||
|
await this.applyBootstrapTranscriptEvidenceOverlay(filteredBootstrapSnapshot);
|
||||||
if (
|
if (
|
||||||
stableRecoveredMixedSnapshot &&
|
stableRecoveredMixedSnapshot &&
|
||||||
!this.needsBootstrapAcceptanceReconcile(
|
!this.needsBootstrapAcceptanceReconcile(
|
||||||
stableRecoveredMixedSnapshot,
|
stableRecoveredMixedSnapshot,
|
||||||
filteredBootstrapSnapshot
|
overlaidBootstrapSnapshot
|
||||||
) &&
|
) &&
|
||||||
!(await this.hasBootstrapTranscriptLaunchReconcileOutcome(stableRecoveredMixedSnapshot))
|
!(await this.hasBootstrapTranscriptLaunchReconcileOutcome(stableRecoveredMixedSnapshot))
|
||||||
) {
|
) {
|
||||||
|
|
@ -19480,10 +19546,10 @@ export class TeamProvisioningService {
|
||||||
? await this.writeLaunchStateSnapshot(teamName, filteredPersisted)
|
? await this.writeLaunchStateSnapshot(teamName, filteredPersisted)
|
||||||
: filteredPersisted;
|
: filteredPersisted;
|
||||||
const preferredSnapshot = choosePreferredLaunchSnapshot(
|
const preferredSnapshot = choosePreferredLaunchSnapshot(
|
||||||
filteredBootstrapSnapshot,
|
overlaidBootstrapSnapshot,
|
||||||
persistedWithCommittedEvidence
|
persistedWithCommittedEvidence
|
||||||
);
|
);
|
||||||
if (preferredSnapshot && preferredSnapshot === filteredBootstrapSnapshot) {
|
if (preferredSnapshot && preferredSnapshot === overlaidBootstrapSnapshot) {
|
||||||
return {
|
return {
|
||||||
snapshot: preferredSnapshot,
|
snapshot: preferredSnapshot,
|
||||||
statuses: snapshotToMemberSpawnStatuses(preferredSnapshot),
|
statuses: snapshotToMemberSpawnStatuses(preferredSnapshot),
|
||||||
|
|
@ -19529,7 +19595,7 @@ export class TeamProvisioningService {
|
||||||
) &&
|
) &&
|
||||||
!this.needsBootstrapAcceptanceReconcile(
|
!this.needsBootstrapAcceptanceReconcile(
|
||||||
persistedWithCommittedEvidence,
|
persistedWithCommittedEvidence,
|
||||||
filteredBootstrapSnapshot
|
overlaidBootstrapSnapshot
|
||||||
) &&
|
) &&
|
||||||
!(await this.hasBootstrapTranscriptLaunchReconcileOutcome(persistedWithCommittedEvidence))
|
!(await this.hasBootstrapTranscriptLaunchReconcileOutcome(persistedWithCommittedEvidence))
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ const {
|
||||||
loginStateListeners,
|
loginStateListeners,
|
||||||
logoutMock,
|
logoutMock,
|
||||||
readAccountMock,
|
readAccountMock,
|
||||||
|
readAccountSnapshotMock,
|
||||||
readRateLimitsMock,
|
readRateLimitsMock,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
binaryResolveMock: vi.fn(),
|
binaryResolveMock: vi.fn(),
|
||||||
|
|
@ -28,6 +29,7 @@ const {
|
||||||
detectLocalAccountStateMock: vi.fn(),
|
detectLocalAccountStateMock: vi.fn(),
|
||||||
getCachedShellEnvMock: vi.fn(),
|
getCachedShellEnvMock: vi.fn(),
|
||||||
readAccountMock: vi.fn(),
|
readAccountMock: vi.fn(),
|
||||||
|
readAccountSnapshotMock: vi.fn(),
|
||||||
readRateLimitsMock: vi.fn(),
|
readRateLimitsMock: vi.fn(),
|
||||||
logoutMock: vi.fn(),
|
logoutMock: vi.fn(),
|
||||||
loginStartMock: vi.fn(),
|
loginStartMock: vi.fn(),
|
||||||
|
|
@ -89,6 +91,7 @@ vi.mock(
|
||||||
'../../../../src/features/codex-account/main/infrastructure/CodexAccountAppServerClient',
|
'../../../../src/features/codex-account/main/infrastructure/CodexAccountAppServerClient',
|
||||||
() => ({
|
() => ({
|
||||||
CodexAccountAppServerClient: class MockCodexAccountAppServerClient {
|
CodexAccountAppServerClient: class MockCodexAccountAppServerClient {
|
||||||
|
readAccountSnapshot = readAccountSnapshotMock;
|
||||||
readAccount = readAccountMock;
|
readAccount = readAccountMock;
|
||||||
readRateLimits = readRateLimitsMock;
|
readRateLimits = readRateLimitsMock;
|
||||||
logout = logoutMock;
|
logout = logoutMock;
|
||||||
|
|
@ -192,6 +195,29 @@ function createRateLimitsResponse() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDeferred<T = void>(): {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve: (value: T | PromiseLike<T>) => void;
|
||||||
|
reject: (error: unknown) => void;
|
||||||
|
} {
|
||||||
|
let resolve: ((value: T | PromiseLike<T>) => void) | null = null;
|
||||||
|
let reject: ((error: unknown) => void) | null = null;
|
||||||
|
const promise = new Promise<T>((fulfill, fail) => {
|
||||||
|
resolve = fulfill;
|
||||||
|
reject = fail;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resolve || !reject) {
|
||||||
|
throw new Error('Failed to create deferred promise.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
promise,
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('createCodexAccountFeature', () => {
|
describe('createCodexAccountFeature', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
@ -204,6 +230,7 @@ describe('createCodexAccountFeature', () => {
|
||||||
hasActiveChatgptAccount: false,
|
hasActiveChatgptAccount: false,
|
||||||
});
|
});
|
||||||
getCachedShellEnvMock.mockReturnValue({});
|
getCachedShellEnvMock.mockReturnValue({});
|
||||||
|
readAccountSnapshotMock.mockReset();
|
||||||
readAccountMock.mockReset();
|
readAccountMock.mockReset();
|
||||||
readRateLimitsMock.mockReset();
|
readRateLimitsMock.mockReset();
|
||||||
logoutMock.mockReset();
|
logoutMock.mockReset();
|
||||||
|
|
@ -217,6 +244,40 @@ describe('createCodexAccountFeature', () => {
|
||||||
};
|
};
|
||||||
loginStateListeners.clear();
|
loginStateListeners.clear();
|
||||||
loginSettledListeners.clear();
|
loginSettledListeners.clear();
|
||||||
|
readAccountSnapshotMock.mockImplementation(
|
||||||
|
async (options: {
|
||||||
|
binaryPath: string;
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
refreshToken?: boolean;
|
||||||
|
includeRateLimits?: boolean;
|
||||||
|
}) => {
|
||||||
|
const account = await readAccountMock(options);
|
||||||
|
if (options.includeRateLimits !== true) {
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
rateLimits: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
rateLimits: {
|
||||||
|
ok: true,
|
||||||
|
payload: await readRateLimitsMock(options),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
rateLimits: {
|
||||||
|
ok: false,
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|
@ -290,6 +351,263 @@ describe('createCodexAccountFeature', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reuses a fresh refresh snapshot when the request does not need stronger data', async () => {
|
||||||
|
readAccountMock.mockResolvedValue({
|
||||||
|
account: createAccountResponse(),
|
||||||
|
initialize: {
|
||||||
|
codexHome: '/Users/test/.codex',
|
||||||
|
platformFamily: 'unix',
|
||||||
|
platformOs: 'macos',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const feature = createCodexAccountFeature({
|
||||||
|
logger: createLoggerPort(),
|
||||||
|
configManager: createConfigManager('chatgpt'),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstSnapshot = await feature.refreshSnapshot();
|
||||||
|
const secondSnapshot = await feature.refreshSnapshot();
|
||||||
|
|
||||||
|
expect(firstSnapshot.managedAccount?.email).toBe('user@example.com');
|
||||||
|
expect(secondSnapshot.managedAccount?.email).toBe('user@example.com');
|
||||||
|
expect(readAccountMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(readAccountSnapshotMock).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
await feature.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reuse a snapshot without rate limits for an includeRateLimits refresh', async () => {
|
||||||
|
readAccountMock.mockResolvedValue({
|
||||||
|
account: createAccountResponse(),
|
||||||
|
initialize: {
|
||||||
|
codexHome: '/Users/test/.codex',
|
||||||
|
platformFamily: 'unix',
|
||||||
|
platformOs: 'macos',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
readRateLimitsMock.mockResolvedValue(createRateLimitsResponse());
|
||||||
|
|
||||||
|
const feature = createCodexAccountFeature({
|
||||||
|
logger: createLoggerPort(),
|
||||||
|
configManager: createConfigManager('chatgpt'),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstSnapshot = await feature.refreshSnapshot();
|
||||||
|
const secondSnapshot = await feature.refreshSnapshot({ includeRateLimits: true });
|
||||||
|
|
||||||
|
expect(firstSnapshot.rateLimits).toBeNull();
|
||||||
|
expect(secondSnapshot.rateLimits?.primary?.usedPercent).toBe(77);
|
||||||
|
expect(readAccountMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(readRateLimitsMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(readAccountSnapshotMock.mock.calls[1]?.[0]).toMatchObject({
|
||||||
|
includeRateLimits: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await feature.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('force-refreshes account truth even when a fresh snapshot exists', async () => {
|
||||||
|
readAccountMock.mockResolvedValue({
|
||||||
|
account: createAccountResponse(),
|
||||||
|
initialize: {
|
||||||
|
codexHome: '/Users/test/.codex',
|
||||||
|
platformFamily: 'unix',
|
||||||
|
platformOs: 'macos',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const feature = createCodexAccountFeature({
|
||||||
|
logger: createLoggerPort(),
|
||||||
|
configManager: createConfigManager('chatgpt'),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await feature.refreshSnapshot();
|
||||||
|
await feature.refreshSnapshot({ forceRefreshToken: true });
|
||||||
|
|
||||||
|
expect(readAccountMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(readAccountMock.mock.calls[1]?.[0]).toMatchObject({
|
||||||
|
refreshToken: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await feature.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not serve stale cached auth state while logout mutation is active', async () => {
|
||||||
|
const logoutDeferred = createDeferred<void>();
|
||||||
|
readAccountMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
account: createAccountResponse(),
|
||||||
|
initialize: {
|
||||||
|
codexHome: '/Users/test/.codex',
|
||||||
|
platformFamily: 'unix',
|
||||||
|
platformOs: 'macos',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValue({
|
||||||
|
account: createAccountResponse({ account: null, requiresOpenaiAuth: false }),
|
||||||
|
initialize: {
|
||||||
|
codexHome: '/Users/test/.codex',
|
||||||
|
platformFamily: 'unix',
|
||||||
|
platformOs: 'macos',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logoutMock.mockReturnValue(logoutDeferred.promise);
|
||||||
|
readRateLimitsMock.mockResolvedValue(createRateLimitsResponse());
|
||||||
|
|
||||||
|
const feature = createCodexAccountFeature({
|
||||||
|
logger: createLoggerPort(),
|
||||||
|
configManager: createConfigManager('chatgpt'),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const initialSnapshot = await feature.refreshSnapshot();
|
||||||
|
expect(initialSnapshot.managedAccount?.email).toBe('user@example.com');
|
||||||
|
|
||||||
|
const logoutPromise = feature.logout();
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(logoutMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
let refreshSettled = false;
|
||||||
|
const refreshDuringLogout = feature.refreshSnapshot().then((snapshot) => {
|
||||||
|
refreshSettled = true;
|
||||||
|
return snapshot;
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(refreshSettled).toBe(false);
|
||||||
|
|
||||||
|
logoutDeferred.resolve();
|
||||||
|
const [duringLogoutSnapshot, afterLogoutSnapshot] = await Promise.all([
|
||||||
|
refreshDuringLogout,
|
||||||
|
logoutPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(duringLogoutSnapshot.managedAccount).toBeNull();
|
||||||
|
expect(afterLogoutSnapshot.managedAccount).toBeNull();
|
||||||
|
expect(afterLogoutSnapshot.requiresOpenaiAuth).toBe(false);
|
||||||
|
expect(readAccountMock.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||||
|
refreshToken: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await feature.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coalesces mixed snapshot callers and preserves auth truth across logout end-to-end', async () => {
|
||||||
|
const firstRead = createDeferred<{
|
||||||
|
account: ReturnType<typeof createAccountResponse>;
|
||||||
|
initialize: { codexHome: string; platformFamily: string; platformOs: string };
|
||||||
|
}>();
|
||||||
|
const healthyRead = {
|
||||||
|
account: createAccountResponse(),
|
||||||
|
initialize: {
|
||||||
|
codexHome: '/Users/test/.codex',
|
||||||
|
platformFamily: 'unix',
|
||||||
|
platformOs: 'macos',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
readAccountMock
|
||||||
|
.mockReturnValueOnce(firstRead.promise)
|
||||||
|
.mockResolvedValueOnce(healthyRead)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
account: createAccountResponse({ account: null, requiresOpenaiAuth: false }),
|
||||||
|
initialize: {
|
||||||
|
codexHome: '/Users/test/.codex',
|
||||||
|
platformFamily: 'unix',
|
||||||
|
platformOs: 'macos',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
readRateLimitsMock.mockResolvedValue(createRateLimitsResponse());
|
||||||
|
logoutMock.mockResolvedValue({});
|
||||||
|
|
||||||
|
const feature = createCodexAccountFeature({
|
||||||
|
logger: createLoggerPort(),
|
||||||
|
configManager: createConfigManager('chatgpt'),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const passiveSnapshot = feature.getSnapshot();
|
||||||
|
const rateLimitedSnapshot = feature.refreshSnapshot({ includeRateLimits: true });
|
||||||
|
const launchReadiness = feature.getLaunchReadiness();
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(readAccountMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
firstRead.resolve(healthyRead);
|
||||||
|
|
||||||
|
const [passiveResult, rateLimitedResult, readinessResult] = await Promise.all([
|
||||||
|
passiveSnapshot,
|
||||||
|
rateLimitedSnapshot,
|
||||||
|
launchReadiness,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(passiveResult.managedAccount?.email).toBe('user@example.com');
|
||||||
|
expect(rateLimitedResult.rateLimits?.primary?.usedPercent).toBe(77);
|
||||||
|
expect(readinessResult.launchAllowed).toBe(true);
|
||||||
|
expect(readAccountMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(readRateLimitsMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const cachedRateLimitedResult = await feature.refreshSnapshot({ includeRateLimits: true });
|
||||||
|
expect(cachedRateLimitedResult.rateLimits?.primary?.usedPercent).toBe(77);
|
||||||
|
expect(readAccountMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(readRateLimitsMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const logoutResult = await feature.logout();
|
||||||
|
expect(logoutResult.managedAccount).toBeNull();
|
||||||
|
expect(logoutResult.requiresOpenaiAuth).toBe(false);
|
||||||
|
expect(logoutResult.launchAllowed).toBe(false);
|
||||||
|
expect(readAccountMock).toHaveBeenCalledTimes(3);
|
||||||
|
expect(readAccountMock.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||||
|
refreshToken: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cachedLoggedOutResult = await feature.getSnapshot();
|
||||||
|
expect(cachedLoggedOutResult.managedAccount).toBeNull();
|
||||||
|
expect(readAccountMock).toHaveBeenCalledTimes(3);
|
||||||
|
} finally {
|
||||||
|
await feature.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps account snapshot healthy when the optional rate limits read fails', async () => {
|
||||||
|
readAccountMock.mockResolvedValue({
|
||||||
|
account: createAccountResponse(),
|
||||||
|
initialize: {
|
||||||
|
codexHome: '/Users/test/.codex',
|
||||||
|
platformFamily: 'unix',
|
||||||
|
platformOs: 'macos',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
readRateLimitsMock.mockRejectedValue(new Error('rate limit service unavailable'));
|
||||||
|
const logger = createLoggerPort();
|
||||||
|
|
||||||
|
const feature = createCodexAccountFeature({
|
||||||
|
logger,
|
||||||
|
configManager: createConfigManager('chatgpt'),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snapshot = await feature.refreshSnapshot({ includeRateLimits: true });
|
||||||
|
|
||||||
|
expect(snapshot.appServerState).toBe('healthy');
|
||||||
|
expect(snapshot.managedAccount?.email).toBe('user@example.com');
|
||||||
|
expect(snapshot.rateLimits).toBeNull();
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith('codex account rate limits refresh failed', {
|
||||||
|
error: 'rate limit service unavailable',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await feature.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the last known managed account during a transient degraded read', async () => {
|
it('keeps the last known managed account during a transient degraded read', async () => {
|
||||||
readAccountMock
|
readAccountMock
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
|
|
@ -360,9 +678,12 @@ describe('createCodexAccountFeature', () => {
|
||||||
logger: createLoggerPort(),
|
logger: createLoggerPort(),
|
||||||
configManager: createConfigManager('chatgpt'),
|
configManager: createConfigManager('chatgpt'),
|
||||||
});
|
});
|
||||||
|
const dateNowSpy = vi.spyOn(Date, 'now');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
dateNowSpy.mockReturnValue(1_776_000_000_000);
|
||||||
const firstSnapshot = await feature.refreshSnapshot();
|
const firstSnapshot = await feature.refreshSnapshot();
|
||||||
|
dateNowSpy.mockReturnValue(1_776_000_006_000);
|
||||||
const secondSnapshot = await feature.refreshSnapshot();
|
const secondSnapshot = await feature.refreshSnapshot();
|
||||||
|
|
||||||
expect(firstSnapshot.managedAccount?.email).toBe('user@example.com');
|
expect(firstSnapshot.managedAccount?.email).toBe('user@example.com');
|
||||||
|
|
@ -373,7 +694,9 @@ describe('createCodexAccountFeature', () => {
|
||||||
expect(secondSnapshot.launchAllowed).toBe(true);
|
expect(secondSnapshot.launchAllowed).toBe(true);
|
||||||
expect(secondSnapshot.launchReadinessState).toBe('ready_chatgpt');
|
expect(secondSnapshot.launchReadinessState).toBe('ready_chatgpt');
|
||||||
expect(secondSnapshot.launchIssueMessage).toBeNull();
|
expect(secondSnapshot.launchIssueMessage).toBeNull();
|
||||||
|
expect(readAccountMock).toHaveBeenCalledTimes(2);
|
||||||
} finally {
|
} finally {
|
||||||
|
dateNowSpy.mockRestore();
|
||||||
await feature.dispose();
|
await feature.dispose();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,139 @@ describe('TeamMemberLogsFinder', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns recent attributed member log file refs in one batch for advisory scans', async () => {
|
||||||
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
|
||||||
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
|
||||||
|
const teamName = 'runtime-advisory-batch';
|
||||||
|
const projectPath = '/Users/test/runtime-advisory-batch';
|
||||||
|
const projectId = '-Users-test-runtime-advisory-batch';
|
||||||
|
const leadSessionId = 'lead-session';
|
||||||
|
const bobSessionId = 'member-bob-session';
|
||||||
|
const now = new Date();
|
||||||
|
const old = new Date(Date.now() - 30 * 60_000);
|
||||||
|
|
||||||
|
await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tmpDir, 'teams', teamName, 'config.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: teamName,
|
||||||
|
projectPath,
|
||||||
|
leadSessionId,
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', agentType: 'team-lead' },
|
||||||
|
{ name: 'Alice', agentType: 'general-purpose' },
|
||||||
|
{ name: 'Bob', agentType: 'general-purpose' },
|
||||||
|
{ name: 'Tom', agentType: 'general-purpose' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectRoot = path.join(tmpDir, 'projects', projectId);
|
||||||
|
await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true });
|
||||||
|
|
||||||
|
const leadPath = path.join(projectRoot, `${leadSessionId}.jsonl`);
|
||||||
|
await fs.writeFile(
|
||||||
|
leadPath,
|
||||||
|
[
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: now.toISOString(),
|
||||||
|
type: 'user',
|
||||||
|
message: { role: 'user', content: `Lead for team "${teamName}" (${teamName})` },
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: now.toISOString(),
|
||||||
|
type: 'system',
|
||||||
|
subtype: 'api_error',
|
||||||
|
retryInMs: 45_000,
|
||||||
|
}),
|
||||||
|
].join('\n') + '\n',
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const alicePath = path.join(projectRoot, leadSessionId, 'subagents', 'agent-alice.jsonl');
|
||||||
|
await fs.writeFile(
|
||||||
|
alicePath,
|
||||||
|
[
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: now.toISOString(),
|
||||||
|
type: 'user',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: `You are Alice, a reviewer on team "${teamName}" (${teamName}).`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: now.toISOString(),
|
||||||
|
type: 'system',
|
||||||
|
subtype: 'api_error',
|
||||||
|
retryInMs: 45_000,
|
||||||
|
}),
|
||||||
|
].join('\n') + '\n',
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const bobPath = path.join(projectRoot, `${bobSessionId}.jsonl`);
|
||||||
|
await fs.writeFile(
|
||||||
|
bobPath,
|
||||||
|
[
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: now.toISOString(),
|
||||||
|
type: 'user',
|
||||||
|
teamName,
|
||||||
|
agentName: 'Bob',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: `You are bootstrapping into team "${teamName}" as member "Bob".`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: now.toISOString(),
|
||||||
|
type: 'system',
|
||||||
|
subtype: 'api_error',
|
||||||
|
retryInMs: 45_000,
|
||||||
|
}),
|
||||||
|
].join('\n') + '\n',
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const tomOldPath = path.join(projectRoot, leadSessionId, 'subagents', 'agent-tom.jsonl');
|
||||||
|
await fs.writeFile(
|
||||||
|
tomOldPath,
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: old.toISOString(),
|
||||||
|
type: 'user',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: `You are Tom, a developer on team "${teamName}" (${teamName}).`,
|
||||||
|
},
|
||||||
|
}) + '\n',
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await fs.utimes(tomOldPath, old, old);
|
||||||
|
|
||||||
|
const finder = new TeamMemberLogsFinder();
|
||||||
|
const refs = await finder.findRecentMemberLogFileRefsByMember(
|
||||||
|
teamName,
|
||||||
|
['team-lead', 'Alice', 'Bob', 'Tom'],
|
||||||
|
Date.now() - 10 * 60_000
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(refs).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ memberName: 'team-lead', filePath: leadPath }),
|
||||||
|
expect.objectContaining({ memberName: 'Alice', filePath: alicePath }),
|
||||||
|
expect.objectContaining({ memberName: 'Bob', filePath: bobPath }),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(refs.some((ref) => ref.memberName === 'Tom')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('listAttributedSubagentFiles only returns files from the current lead session for live tracking', async () => {
|
it('listAttributedSubagentFiles only returns files from the current lead session for live tracking', async () => {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
|
||||||
setClaudeBasePathOverride(tmpDir);
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
|
||||||
|
import { TeamMemberLogsFinder } from '../../../../src/main/services/team/TeamMemberLogsFinder';
|
||||||
|
import { TeamMemberRuntimeAdvisoryService } from '../../../../src/main/services/team/TeamMemberRuntimeAdvisoryService';
|
||||||
|
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
||||||
|
|
||||||
|
const LIVE_TEAM = process.env.LIVE_RUNTIME_ADVISORY_TEAM?.trim();
|
||||||
|
const LIVE_CLAUDE_BASE =
|
||||||
|
process.env.LIVE_RUNTIME_ADVISORY_CLAUDE_BASE?.trim() || path.join(os.homedir(), '.claude');
|
||||||
|
|
||||||
|
const describeLive = LIVE_TEAM && LIVE_CLAUDE_BASE ? describe : describe.skip;
|
||||||
|
|
||||||
|
describeLive('TeamMemberRuntimeAdvisoryService live logs smoke', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
setClaudeBasePathOverride(LIVE_CLAUDE_BASE);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
setClaudeBasePathOverride(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches legacy member log attribution on real team logs', async () => {
|
||||||
|
const config = await new TeamConfigReader().getConfig(LIVE_TEAM!);
|
||||||
|
const memberNames = (config?.members ?? [])
|
||||||
|
.filter((member) => member.name && member.name !== 'user' && !member.removedAt)
|
||||||
|
.map((member) => member.name);
|
||||||
|
|
||||||
|
expect(memberNames.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const finder = new TeamMemberLogsFinder();
|
||||||
|
const batchRefs = await finder.findRecentMemberLogFileRefsByMember(
|
||||||
|
LIVE_TEAM!,
|
||||||
|
memberNames,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const batchFilesByMember = new Map<string, Set<string>>();
|
||||||
|
for (const ref of batchRefs) {
|
||||||
|
const files = batchFilesByMember.get(ref.memberName) ?? new Set<string>();
|
||||||
|
files.add(ref.filePath);
|
||||||
|
batchFilesByMember.set(ref.memberName, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const memberName of memberNames) {
|
||||||
|
const legacyFiles = new Set(
|
||||||
|
(await finder.findMemberLogs(LIVE_TEAM!, memberName, null))
|
||||||
|
.map((summary) => summary.filePath)
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
const batchFiles = batchFilesByMember.get(memberName) ?? new Set<string>();
|
||||||
|
|
||||||
|
expect([...legacyFiles].sort()).toEqual([...batchFiles].sort());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads runtime advisories through the batch path without failing on real team logs', async () => {
|
||||||
|
const config = await new TeamConfigReader().getConfig(LIVE_TEAM!);
|
||||||
|
const members = (config?.members ?? [])
|
||||||
|
.filter((member) => member.name && member.name !== 'user' && !member.removedAt)
|
||||||
|
.map((member) => ({ name: member.name, removedAt: member.removedAt }));
|
||||||
|
|
||||||
|
const advisories = await new TeamMemberRuntimeAdvisoryService(
|
||||||
|
new TeamMemberLogsFinder()
|
||||||
|
).getMemberAdvisories(LIVE_TEAM!, members);
|
||||||
|
|
||||||
|
expect(advisories).toBeInstanceOf(Map);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -48,6 +48,9 @@ function createStubbedServiceHarness() {
|
||||||
findMemberLogs: vi.fn(async (_teamName: string, memberName: string) => [
|
findMemberLogs: vi.fn(async (_teamName: string, memberName: string) => [
|
||||||
{ filePath: `/logs/${memberName}.jsonl` },
|
{ filePath: `/logs/${memberName}.jsonl` },
|
||||||
]),
|
]),
|
||||||
|
findRecentMemberLogFileRefsByMember: undefined as
|
||||||
|
| undefined
|
||||||
|
| ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<unknown[]>>>,
|
||||||
};
|
};
|
||||||
const service = new TeamMemberRuntimeAdvisoryService(logsFinder as never);
|
const service = new TeamMemberRuntimeAdvisoryService(logsFinder as never);
|
||||||
const advisoryByFilePath = new Map<string, MemberRuntimeAdvisory | null>();
|
const advisoryByFilePath = new Map<string, MemberRuntimeAdvisory | null>();
|
||||||
|
|
@ -386,6 +389,61 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
|
||||||
expect(Array.from(advisories.keys())).toEqual(['Alice', 'Bob', 'Charlie']);
|
expect(Array.from(advisories.keys())).toEqual(['Alice', 'Bob', 'Charlie']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses batch member log refs once instead of scanning logs per missing member', async () => {
|
||||||
|
const { service, logsFinder, advisoryByFilePath } = createStubbedServiceHarness();
|
||||||
|
logsFinder.findRecentMemberLogFileRefsByMember = vi.fn(async () => [
|
||||||
|
{ memberName: 'Alice', filePath: '/logs/alice-new.jsonl', mtimeMs: 300 },
|
||||||
|
{ memberName: 'Alice', filePath: '/logs/alice-old.jsonl', mtimeMs: 100 },
|
||||||
|
{ memberName: 'Bob', filePath: '/logs/bob.jsonl', mtimeMs: 200 },
|
||||||
|
]);
|
||||||
|
advisoryByFilePath.set('/logs/alice-new.jsonl', null);
|
||||||
|
advisoryByFilePath.set('/logs/alice-old.jsonl', buildRetryingAdvisory('alice-old'));
|
||||||
|
advisoryByFilePath.set('/logs/bob.jsonl', buildRetryingAdvisory('bob'));
|
||||||
|
|
||||||
|
const advisories = await service.getMemberAdvisories('signal-ops', [
|
||||||
|
buildMember('Alice'),
|
||||||
|
buildMember('Bob'),
|
||||||
|
buildMember('Charlie'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(logsFinder.findRecentMemberLogFileRefsByMember).toHaveBeenCalledTimes(1);
|
||||||
|
expect(logsFinder.findRecentMemberLogFileRefsByMember).toHaveBeenCalledWith(
|
||||||
|
'signal-ops',
|
||||||
|
['Alice', 'Bob', 'Charlie'],
|
||||||
|
expect.any(Number)
|
||||||
|
);
|
||||||
|
expect(logsFinder.findMemberLogs).not.toHaveBeenCalled();
|
||||||
|
expect(advisories.get('Alice')?.message).toBe('retry:alice-old');
|
||||||
|
expect(advisories.get('Bob')?.message).toBe('retry:bob');
|
||||||
|
expect(advisories.has('Charlie')).toBe(false);
|
||||||
|
|
||||||
|
await service.getMemberAdvisories('signal-ops', [
|
||||||
|
buildMember('Alice'),
|
||||||
|
buildMember('Bob'),
|
||||||
|
buildMember('Charlie'),
|
||||||
|
]);
|
||||||
|
expect(logsFinder.findRecentMemberLogFileRefsByMember).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to per-member log scans when the batch log ref lookup fails', async () => {
|
||||||
|
const { service, logsFinder } = createStubbedServiceHarness();
|
||||||
|
logsFinder.findRecentMemberLogFileRefsByMember = vi.fn(async () => {
|
||||||
|
throw new Error('batch unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
const advisories = await service.getMemberAdvisories('signal-ops', [
|
||||||
|
buildMember('Alice'),
|
||||||
|
buildMember('Bob'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(logsFinder.findRecentMemberLogFileRefsByMember).toHaveBeenCalledTimes(1);
|
||||||
|
expect(logsFinder.findMemberLogs.mock.calls.map((call) => call[1])).toEqual([
|
||||||
|
'Alice',
|
||||||
|
'Bob',
|
||||||
|
]);
|
||||||
|
expect(Array.from(advisories.keys())).toEqual(['Alice', 'Bob']);
|
||||||
|
});
|
||||||
|
|
||||||
it('limits concurrent member advisory log scans', async () => {
|
it('limits concurrent member advisory log scans', async () => {
|
||||||
const { service, logsFinder } = createStubbedServiceHarness();
|
const { service, logsFinder } = createStubbedServiceHarness();
|
||||||
let activeScans = 0;
|
let activeScans = 0;
|
||||||
|
|
|
||||||
|
|
@ -264,7 +264,13 @@ function writeLaunchState(
|
||||||
|
|
||||||
function writeBootstrapState(
|
function writeBootstrapState(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
members: { name: string; status: string; lastAttemptAt?: number; lastObservedAt?: number }[],
|
members: {
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
lastAttemptAt?: number;
|
||||||
|
lastObservedAt?: number;
|
||||||
|
failureReason?: string;
|
||||||
|
}[],
|
||||||
updatedAt = new Date().toISOString()
|
updatedAt = new Date().toISOString()
|
||||||
): void {
|
): void {
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|
@ -10108,6 +10114,143 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(result.statuses.alice?.error).toBeUndefined();
|
expect(result.statuses.alice?.error).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('heals terminal bootstrap-state failures when transcript shows successful member_briefing', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const teamName = 'zz-unit-bootstrap-state-transcript-heals';
|
||||||
|
const leadSessionId = 'lead-session';
|
||||||
|
const memberSessionId = 'jack-session';
|
||||||
|
const projectPath = '/Users/test/proj';
|
||||||
|
const projectId = '-Users-test-proj';
|
||||||
|
const acceptedAt = new Date(Date.now() - 90_000).toISOString();
|
||||||
|
const successAt = new Date(Date.now() - 60_000).toISOString();
|
||||||
|
const failureAt = new Date(Date.now() - 30_000).toISOString();
|
||||||
|
|
||||||
|
writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']);
|
||||||
|
writeBootstrapState(
|
||||||
|
teamName,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'jack',
|
||||||
|
status: 'failed',
|
||||||
|
lastAttemptAt: Date.parse(acceptedAt),
|
||||||
|
lastObservedAt: Date.parse(failureAt),
|
||||||
|
failureReason: 'Teammate was registered but did not bootstrap-confirm before timeout.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
failureAt
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectRoot = path.join(tempProjectsBase, projectId);
|
||||||
|
fs.mkdirSync(projectRoot, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, `${memberSessionId}.jsonl`),
|
||||||
|
[
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: acceptedAt,
|
||||||
|
teamName,
|
||||||
|
agentName: 'jack',
|
||||||
|
type: 'user',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: `You are bootstrapping into team "${teamName}" as member "jack".`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: successAt,
|
||||||
|
teamName,
|
||||||
|
agentName: 'jack',
|
||||||
|
type: 'user',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'item_1',
|
||||||
|
content: `Member briefing for jack on team "${teamName}" (${teamName}).\nTask briefing for jack:\nNo actionable tasks.`,
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
].join('\n') + '\n',
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
||||||
|
expect(result.teamLaunchState).toBe('clean_success');
|
||||||
|
expect(result.statuses.jack).toMatchObject({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
hardFailure: false,
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not heal bootstrap-state failures from stale pre-launch transcript success', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const teamName = 'zz-unit-bootstrap-state-stale-transcript-ignored';
|
||||||
|
const leadSessionId = 'lead-session';
|
||||||
|
const memberSessionId = 'jack-session';
|
||||||
|
const projectPath = '/Users/test/proj';
|
||||||
|
const projectId = '-Users-test-proj';
|
||||||
|
const staleSuccessAt = new Date(Date.now() - 180_000).toISOString();
|
||||||
|
const acceptedAt = new Date(Date.now() - 90_000).toISOString();
|
||||||
|
const failureAt = new Date(Date.now() - 30_000).toISOString();
|
||||||
|
|
||||||
|
writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']);
|
||||||
|
writeBootstrapState(
|
||||||
|
teamName,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'jack',
|
||||||
|
status: 'failed',
|
||||||
|
lastAttemptAt: Date.parse(acceptedAt),
|
||||||
|
lastObservedAt: Date.parse(failureAt),
|
||||||
|
failureReason: 'Teammate was registered but did not bootstrap-confirm before timeout.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
failureAt
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectRoot = path.join(tempProjectsBase, projectId);
|
||||||
|
fs.mkdirSync(projectRoot, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, `${memberSessionId}.jsonl`),
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: staleSuccessAt,
|
||||||
|
teamName,
|
||||||
|
agentName: 'jack',
|
||||||
|
type: 'user',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'item_1',
|
||||||
|
content: `Member briefing for jack on team "${teamName}" (${teamName}).\nTask briefing for jack:\nNo actionable tasks.`,
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}) + '\n',
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
||||||
|
expect(result.teamLaunchState).toBe('partial_failure');
|
||||||
|
expect(result.statuses.jack).toMatchObject({
|
||||||
|
status: 'error',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('does not classify the bootstrap instruction prompt as a member launch failure', async () => {
|
it('does not classify the bootstrap instruction prompt as a member launch failure', async () => {
|
||||||
allowConsoleLogs();
|
allowConsoleLogs();
|
||||||
const teamName = 'zz-unit-bootstrap-prompt-not-failure';
|
const teamName = 'zz-unit-bootstrap-prompt-not-failure';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue