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 mutationQueueRelease: (() => void) | null = null;
|
||||
private activeMutationCount = 0;
|
||||
private disposed = false;
|
||||
|
||||
constructor(
|
||||
private readonly logger: LoggerPort,
|
||||
|
|
@ -290,8 +291,12 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
}
|
||||
|
||||
async getSnapshot(): Promise<CodexAccountSnapshotDto> {
|
||||
if (this.snapshotCache && Date.now() - this.snapshotObservedAt <= SNAPSHOT_CACHE_TTL_MS) {
|
||||
return deepClone(this.snapshotCache);
|
||||
const cached = this.getCachedSnapshotForOptions({
|
||||
includeRateLimits: false,
|
||||
forceRefreshToken: false,
|
||||
});
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
return this.refreshSnapshot();
|
||||
|
|
@ -301,10 +306,13 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
includeRateLimits?: boolean;
|
||||
forceRefreshToken?: boolean;
|
||||
}): Promise<CodexAccountSnapshotDto> {
|
||||
this.pendingRefreshOptions = mergeRefreshOptions(
|
||||
this.pendingRefreshOptions,
|
||||
normalizeRefreshOptions(options)
|
||||
);
|
||||
const normalizedOptions = normalizeRefreshOptions(options);
|
||||
const cached = this.getCachedSnapshotForOptions(normalizedOptions);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
this.pendingRefreshOptions = mergeRefreshOptions(this.pendingRefreshOptions, normalizedOptions);
|
||||
|
||||
if (!this.refreshPromise) {
|
||||
this.refreshPromise = this.drainRefreshQueue().finally(() => {
|
||||
|
|
@ -386,6 +394,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true;
|
||||
await this.loginSessionManager.dispose();
|
||||
this.listeners.clear();
|
||||
this.snapshotCache = null;
|
||||
|
|
@ -410,7 +419,8 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
this.pendingRefreshOptions = null;
|
||||
await this.mutationQueue.catch(() => undefined);
|
||||
|
||||
lastSnapshot = await this.loadSnapshot(nextOptions);
|
||||
lastSnapshot =
|
||||
this.getCachedSnapshotForOptions(nextOptions) ?? (await this.loadSnapshot(nextOptions));
|
||||
}
|
||||
|
||||
if (!lastSnapshot) {
|
||||
|
|
@ -464,12 +474,17 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
let accountPayload = this.lastKnownAccount?.payload ?? null;
|
||||
let requiresOpenaiAuth: boolean | null = accountPayload?.requiresOpenaiAuth ?? null;
|
||||
let runtimeContext = createRuntimeContext(binaryPath, null);
|
||||
const cachedRateLimitsAreFresh = this.hasFreshRateLimits(now);
|
||||
const shouldRequestRateLimits =
|
||||
options?.includeRateLimits === true && !cachedRateLimitsAreFresh;
|
||||
let rateLimitsReadFailure: unknown | null = null;
|
||||
|
||||
try {
|
||||
const accountResult = await this.appServerClient.readAccount({
|
||||
const accountResult = await this.appServerClient.readAccountSnapshot({
|
||||
binaryPath,
|
||||
env,
|
||||
refreshToken: options?.forceRefreshToken ?? false,
|
||||
includeRateLimits: shouldRequestRateLimits,
|
||||
});
|
||||
runtimeContext = createRuntimeContext(binaryPath, accountResult.initialize.codexHome);
|
||||
if (runtimeContext.codexHome) {
|
||||
|
|
@ -498,6 +513,14 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
observedAt: now,
|
||||
};
|
||||
}
|
||||
if (accountResult.rateLimits?.ok) {
|
||||
this.lastKnownRateLimits = {
|
||||
payload: accountResult.rateLimits.payload,
|
||||
observedAt: now,
|
||||
};
|
||||
} else if (accountResult.rateLimits) {
|
||||
rateLimitsReadFailure = accountResult.rateLimits.error;
|
||||
}
|
||||
} catch (error) {
|
||||
const failure = classifyAppServerFailure(error);
|
||||
appServerState = failure.appServerState;
|
||||
|
|
@ -525,35 +548,18 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
|
||||
let rateLimits: CodexRateLimitSnapshotDto | null = null;
|
||||
const shouldLoadRateLimits =
|
||||
options?.includeRateLimits === true ||
|
||||
(this.lastKnownRateLimits !== null &&
|
||||
now - this.lastKnownRateLimits.observedAt <= RATE_LIMITS_CACHE_TTL_MS);
|
||||
options?.includeRateLimits === true || this.hasFreshRateLimits(now);
|
||||
|
||||
if (shouldLoadRateLimits) {
|
||||
try {
|
||||
if (
|
||||
this.lastKnownRateLimits &&
|
||||
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) {
|
||||
if (this.hasFreshRateLimits(now) && this.lastKnownRateLimits) {
|
||||
rateLimits = asRateLimits(this.lastKnownRateLimits.payload.rateLimits);
|
||||
} else if (rateLimitsReadFailure) {
|
||||
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 {
|
||||
if (this.disposed) {
|
||||
return deepClone(nextSnapshot);
|
||||
}
|
||||
|
||||
this.snapshotCache = deepClone(nextSnapshot);
|
||||
this.snapshotObservedAt = Date.now();
|
||||
const snapshot = deepClone(nextSnapshot);
|
||||
|
|
@ -600,6 +610,36 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
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> {
|
||||
if (!this.snapshotCache) {
|
||||
return this.refreshSnapshot();
|
||||
|
|
|
|||
|
|
@ -12,26 +12,39 @@ const ACCOUNT_RATE_LIMITS_TIMEOUT_MS = 4_500;
|
|||
const ACCOUNT_LOGOUT_TIMEOUT_MS = 3_500;
|
||||
const INITIALIZE_TIMEOUT_MS = 6_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 {
|
||||
constructor(private readonly sessionFactory: CodexAppServerSessionFactory) {}
|
||||
|
||||
async readAccount(options: {
|
||||
async readAccountSnapshot(options: {
|
||||
binaryPath: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
refreshToken?: boolean;
|
||||
includeRateLimits?: boolean;
|
||||
}): Promise<{
|
||||
account: CodexAppServerGetAccountResponse;
|
||||
rateLimits: CodexAccountRateLimitsReadResult | null;
|
||||
initialize: { codexHome: string; platformFamily: string; platformOs: string };
|
||||
}> {
|
||||
const includeRateLimits = options.includeRateLimits === true;
|
||||
|
||||
return this.sessionFactory.withSession(
|
||||
{
|
||||
binaryPath: options.binaryPath,
|
||||
env: options.env,
|
||||
requestTimeoutMs: ACCOUNT_READ_TIMEOUT_MS,
|
||||
requestTimeoutMs: includeRateLimits
|
||||
? ACCOUNT_RATE_LIMITS_TIMEOUT_MS
|
||||
: ACCOUNT_READ_TIMEOUT_MS,
|
||||
initializeTimeoutMs: INITIALIZE_TIMEOUT_MS,
|
||||
totalTimeoutMs: TOTAL_TIMEOUT_MS,
|
||||
label: 'codex app-server account/read',
|
||||
totalTimeoutMs: includeRateLimits ? TOTAL_WITH_RATE_LIMITS_TIMEOUT_MS : TOTAL_TIMEOUT_MS,
|
||||
label: includeRateLimits
|
||||
? 'codex app-server account/read with rateLimits/read'
|
||||
: 'codex app-server account/read',
|
||||
},
|
||||
async (session) => {
|
||||
const account = await session.request<CodexAppServerGetAccountResponse>(
|
||||
|
|
@ -42,8 +55,25 @@ export class CodexAccountAppServerClient {
|
|||
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 {
|
||||
account,
|
||||
rateLimits,
|
||||
initialize: {
|
||||
codexHome: session.initializeResponse.codexHome,
|
||||
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: {
|
||||
binaryPath: string;
|
||||
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;
|
||||
};
|
||||
|
||||
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 {
|
||||
private readonly fileMentionsCache = new Map<string, boolean>();
|
||||
private readonly attributionCache = new Map<
|
||||
|
|
@ -849,6 +878,116 @@ export class TeamMemberLogsFinder {
|
|||
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.
|
||||
* Prefer structured MCP/TaskUpdate markers for modern sessions.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,26 @@ import * as fs from 'fs/promises';
|
|||
|
||||
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 CACHE_TTL_MS = 30_000;
|
||||
|
|
@ -125,7 +144,9 @@ export class TeamMemberRuntimeAdvisoryService {
|
|||
Promise<Map<string, MemberRuntimeAdvisory>>
|
||||
>();
|
||||
|
||||
constructor(private readonly logsFinder: TeamMemberLogsFinder = new TeamMemberLogsFinder()) {}
|
||||
constructor(
|
||||
private readonly logsFinder: RuntimeAdvisoryLogsFinder = new TeamMemberLogsFinder()
|
||||
) {}
|
||||
|
||||
async getMemberAdvisories(
|
||||
teamName: string,
|
||||
|
|
@ -210,14 +231,7 @@ export class TeamMemberRuntimeAdvisoryService {
|
|||
}
|
||||
|
||||
if (membersToFetch.length > 0) {
|
||||
const fetched = await mapLimit(
|
||||
membersToFetch,
|
||||
ADVISORY_FETCH_CONCURRENCY,
|
||||
async (memberName) => {
|
||||
const advisory = await this.findRecentMemberAdvisory(teamName, memberName);
|
||||
return [memberName, advisory] as const;
|
||||
}
|
||||
);
|
||||
const fetched = await this.findRecentMemberAdvisories(teamName, membersToFetch);
|
||||
const fetchedAt = Date.now();
|
||||
for (const [memberName, advisory] of fetched) {
|
||||
const normalizedMemberName = this.normalizeToken(memberName);
|
||||
|
|
@ -296,11 +310,83 @@ export class TeamMemberRuntimeAdvisoryService {
|
|||
memberName,
|
||||
Date.now() - LOOKBACK_MS
|
||||
);
|
||||
for (const summary of summaries) {
|
||||
if (!summary.filePath) {
|
||||
return this.findRecentMemberAdvisoryInFiles(
|
||||
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;
|
||||
}
|
||||
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) {
|
||||
return advisory;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19384,6 +19384,70 @@ export class TeamProvisioningService {
|
|||
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(
|
||||
snapshot: PersistedTeamLaunchSnapshot | null,
|
||||
bootstrapSnapshot: PersistedTeamLaunchSnapshot | null
|
||||
|
|
@ -19449,11 +19513,13 @@ export class TeamProvisioningService {
|
|||
const filteredBootstrapSnapshot = bootstrapSnapshot
|
||||
? this.filterRemovedMembersFromLaunchSnapshot(bootstrapSnapshot, metaMembers)
|
||||
: null;
|
||||
const overlaidBootstrapSnapshot =
|
||||
await this.applyBootstrapTranscriptEvidenceOverlay(filteredBootstrapSnapshot);
|
||||
if (
|
||||
stableRecoveredMixedSnapshot &&
|
||||
!this.needsBootstrapAcceptanceReconcile(
|
||||
stableRecoveredMixedSnapshot,
|
||||
filteredBootstrapSnapshot
|
||||
overlaidBootstrapSnapshot
|
||||
) &&
|
||||
!(await this.hasBootstrapTranscriptLaunchReconcileOutcome(stableRecoveredMixedSnapshot))
|
||||
) {
|
||||
|
|
@ -19480,10 +19546,10 @@ export class TeamProvisioningService {
|
|||
? await this.writeLaunchStateSnapshot(teamName, filteredPersisted)
|
||||
: filteredPersisted;
|
||||
const preferredSnapshot = choosePreferredLaunchSnapshot(
|
||||
filteredBootstrapSnapshot,
|
||||
overlaidBootstrapSnapshot,
|
||||
persistedWithCommittedEvidence
|
||||
);
|
||||
if (preferredSnapshot && preferredSnapshot === filteredBootstrapSnapshot) {
|
||||
if (preferredSnapshot && preferredSnapshot === overlaidBootstrapSnapshot) {
|
||||
return {
|
||||
snapshot: preferredSnapshot,
|
||||
statuses: snapshotToMemberSpawnStatuses(preferredSnapshot),
|
||||
|
|
@ -19529,7 +19595,7 @@ export class TeamProvisioningService {
|
|||
) &&
|
||||
!this.needsBootstrapAcceptanceReconcile(
|
||||
persistedWithCommittedEvidence,
|
||||
filteredBootstrapSnapshot
|
||||
overlaidBootstrapSnapshot
|
||||
) &&
|
||||
!(await this.hasBootstrapTranscriptLaunchReconcileOutcome(persistedWithCommittedEvidence))
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const {
|
|||
loginStateListeners,
|
||||
logoutMock,
|
||||
readAccountMock,
|
||||
readAccountSnapshotMock,
|
||||
readRateLimitsMock,
|
||||
} = vi.hoisted(() => ({
|
||||
binaryResolveMock: vi.fn(),
|
||||
|
|
@ -28,6 +29,7 @@ const {
|
|||
detectLocalAccountStateMock: vi.fn(),
|
||||
getCachedShellEnvMock: vi.fn(),
|
||||
readAccountMock: vi.fn(),
|
||||
readAccountSnapshotMock: vi.fn(),
|
||||
readRateLimitsMock: vi.fn(),
|
||||
logoutMock: vi.fn(),
|
||||
loginStartMock: vi.fn(),
|
||||
|
|
@ -89,6 +91,7 @@ vi.mock(
|
|||
'../../../../src/features/codex-account/main/infrastructure/CodexAccountAppServerClient',
|
||||
() => ({
|
||||
CodexAccountAppServerClient: class MockCodexAccountAppServerClient {
|
||||
readAccountSnapshot = readAccountSnapshotMock;
|
||||
readAccount = readAccountMock;
|
||||
readRateLimits = readRateLimitsMock;
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -204,6 +230,7 @@ describe('createCodexAccountFeature', () => {
|
|||
hasActiveChatgptAccount: false,
|
||||
});
|
||||
getCachedShellEnvMock.mockReturnValue({});
|
||||
readAccountSnapshotMock.mockReset();
|
||||
readAccountMock.mockReset();
|
||||
readRateLimitsMock.mockReset();
|
||||
logoutMock.mockReset();
|
||||
|
|
@ -217,6 +244,40 @@ describe('createCodexAccountFeature', () => {
|
|||
};
|
||||
loginStateListeners.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(() => {
|
||||
|
|
@ -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 () => {
|
||||
readAccountMock
|
||||
.mockResolvedValueOnce({
|
||||
|
|
@ -360,9 +678,12 @@ describe('createCodexAccountFeature', () => {
|
|||
logger: createLoggerPort(),
|
||||
configManager: createConfigManager('chatgpt'),
|
||||
});
|
||||
const dateNowSpy = vi.spyOn(Date, 'now');
|
||||
|
||||
try {
|
||||
dateNowSpy.mockReturnValue(1_776_000_000_000);
|
||||
const firstSnapshot = await feature.refreshSnapshot();
|
||||
dateNowSpy.mockReturnValue(1_776_000_006_000);
|
||||
const secondSnapshot = await feature.refreshSnapshot();
|
||||
|
||||
expect(firstSnapshot.managedAccount?.email).toBe('user@example.com');
|
||||
|
|
@ -373,7 +694,9 @@ describe('createCodexAccountFeature', () => {
|
|||
expect(secondSnapshot.launchAllowed).toBe(true);
|
||||
expect(secondSnapshot.launchReadinessState).toBe('ready_chatgpt');
|
||||
expect(secondSnapshot.launchIssueMessage).toBeNull();
|
||||
expect(readAccountMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
dateNowSpy.mockRestore();
|
||||
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 () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
|
||||
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) => [
|
||||
{ filePath: `/logs/${memberName}.jsonl` },
|
||||
]),
|
||||
findRecentMemberLogFileRefsByMember: undefined as
|
||||
| undefined
|
||||
| ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<unknown[]>>>,
|
||||
};
|
||||
const service = new TeamMemberRuntimeAdvisoryService(logsFinder as never);
|
||||
const advisoryByFilePath = new Map<string, MemberRuntimeAdvisory | null>();
|
||||
|
|
@ -386,6 +389,61 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
|
|||
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 () => {
|
||||
const { service, logsFinder } = createStubbedServiceHarness();
|
||||
let activeScans = 0;
|
||||
|
|
|
|||
|
|
@ -264,7 +264,13 @@ function writeLaunchState(
|
|||
|
||||
function writeBootstrapState(
|
||||
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()
|
||||
): void {
|
||||
fs.writeFileSync(
|
||||
|
|
@ -10108,6 +10114,143 @@ describe('TeamProvisioningService', () => {
|
|||
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 () => {
|
||||
allowConsoleLogs();
|
||||
const teamName = 'zz-unit-bootstrap-prompt-not-failure';
|
||||
|
|
|
|||
Loading…
Reference in a new issue