fix(team): harden launch reconciliation and diagnostics

This commit is contained in:
777genius 2026-05-01 20:10:14 +03:00
parent a0170aed39
commit 601d51d54e
11 changed files with 1316 additions and 55 deletions

View file

@ -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();

View file

@ -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;

View file

@ -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']);
});
});

View file

@ -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.

View file

@ -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;
}

View file

@ -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))
) {

View file

@ -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();
}
});

View file

@ -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);

View file

@ -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);
});
});

View file

@ -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;

View file

@ -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';