agent-ecosystem/test/main/services/team/TeamConfigReader.test.ts

1097 lines
35 KiB
TypeScript

import * as fs from 'fs/promises';
import * as nodeFs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({
teamsBase: '',
}));
vi.mock('../../../../src/main/utils/pathDecoder', () => ({
getTeamsBasePath: () => hoisted.teamsBase,
}));
vi.mock('../../../../src/main/services/team/TeamFsWorkerClient', () => ({
getTeamFsWorkerClient: () => ({
isAvailable: () => false,
}),
}));
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
import { createPersistedLaunchSummaryProjection } from '../../../../src/main/services/team/TeamLaunchSummaryProjection';
function createDeferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('TeamConfigReader', () => {
let tempDir = '';
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-config-reader-'));
hoisted.teamsBase = tempDir;
});
afterEach(async () => {
vi.useRealTimers();
vi.restoreAllMocks();
TeamConfigReader.clearCacheForTests();
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
}
hoisted.teamsBase = '';
});
it('uses compact launch summary projection when launch-state.json is oversized', async () => {
const teamName = 'mixed-team';
const teamDir = path.join(tempDir, teamName);
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
path.join(teamDir, 'config.json'),
JSON.stringify({
name: 'Mixed Team',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
await fs.writeFile(path.join(teamDir, 'launch-state.json'), 'x'.repeat(40 * 1024), 'utf8');
await fs.writeFile(
path.join(teamDir, 'launch-summary.json'),
JSON.stringify(
createPersistedLaunchSummaryProjection({
version: 2,
teamName,
updatedAt: '2026-04-22T12:00:00.000Z',
launchPhase: 'finished',
expectedMembers: ['alice', 'bob'],
bootstrapExpectedMembers: ['alice'],
members: {
alice: {
name: 'alice',
providerId: 'codex',
laneId: 'primary',
laneKind: 'primary',
laneOwnerProviderId: 'codex',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
lastEvaluatedAt: '2026-04-22T12:00:00.000Z',
},
bob: {
name: 'bob',
providerId: 'opencode',
laneId: 'secondary:opencode:bob',
laneKind: 'secondary',
laneOwnerProviderId: 'opencode',
launchState: 'failed_to_start',
agentToolAccepted: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: true,
hardFailureReason: 'Side lane failed',
lastEvaluatedAt: '2026-04-22T12:00:00.000Z',
},
},
summary: {
confirmedCount: 1,
pendingCount: 0,
failedCount: 1,
runtimeAlivePendingCount: 0,
},
teamLaunchState: 'partial_failure',
} as never),
null,
2
),
'utf8'
);
await fs.writeFile(
path.join(teamDir, 'bootstrap-state.json'),
JSON.stringify({
version: 1,
teamName,
runId: 'bootstrap-run-1',
ownerPid: process.pid,
startedAt: Date.parse('2026-04-22T12:01:00.000Z'),
updatedAt: Date.parse('2026-04-22T12:01:00.000Z'),
phase: 'spawning_members',
members: [{ name: 'alice', status: 'pending' }],
}),
'utf8'
);
const reader = new TeamConfigReader();
const teams = await reader.listTeams();
expect(teams).toHaveLength(1);
expect(teams[0]).toMatchObject({
teamName,
displayName: 'Mixed Team',
partialLaunchFailure: true,
expectedMemberCount: 2,
confirmedMemberCount: 1,
missingMembers: ['bob'],
teamLaunchState: 'partial_failure',
confirmedCount: 1,
pendingCount: 0,
failedCount: 1,
});
});
it('projects bootstrap-confirmed provisioned-but-not-alive launch state as settled', async () => {
const teamName = 'signal-ops';
const teamDir = path.join(tempDir, teamName);
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
path.join(teamDir, 'config.json'),
JSON.stringify({
name: 'Signal Ops',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
await fs.writeFile(
path.join(teamDir, 'launch-state.json'),
JSON.stringify({
version: 2,
teamName,
updatedAt: '2026-05-25T20:14:02.147Z',
launchPhase: 'finished',
expectedMembers: ['tom'],
members: {
tom: {
name: 'tom',
providerId: 'anthropic',
launchState: 'failed_to_start',
agentToolAccepted: true,
runtimeAlive: false,
bootstrapConfirmed: true,
hardFailure: true,
hardFailureReason: 'CLI process exited (code 1) \u2014 team provisioned but not alive',
livenessKind: 'confirmed_bootstrap',
runtimeDiagnostic:
'runtime pid could not be verified because process table is unavailable',
runtimeDiagnosticSeverity: 'warning',
firstSpawnAcceptedAt: '2026-05-25T20:13:46.326Z',
lastHeartbeatAt: '2026-05-25T20:13:56.110Z',
lastEvaluatedAt: '2026-05-25T20:14:02.147Z',
},
},
summary: {
confirmedCount: 0,
pendingCount: 0,
failedCount: 1,
runtimeAlivePendingCount: 0,
},
teamLaunchState: 'partial_failure',
}),
'utf8'
);
const reader = new TeamConfigReader();
const teams = await reader.listTeams();
expect(teams).toHaveLength(1);
expect(teams[0]).toMatchObject({
teamName,
displayName: 'Signal Ops',
teamLaunchState: 'clean_success',
confirmedMemberCount: 1,
confirmedCount: 1,
failedCount: 0,
});
expect(teams[0]).not.toMatchObject({
partialLaunchFailure: true,
missingMembers: ['tom'],
});
});
it('does not invent a partial-failure summary from artifact counts for mixed-aware teams when canonical launch truth is unavailable', async () => {
const teamName = 'mixed-aware-team';
const teamDir = path.join(tempDir, teamName);
await fs.mkdir(path.join(teamDir, 'inboxes'), { recursive: true });
await fs.writeFile(
path.join(teamDir, 'config.json'),
JSON.stringify({
name: 'Mixed Aware Team',
leadSessionId: 'lead-session-1',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
await fs.writeFile(
path.join(teamDir, 'team.meta.json'),
JSON.stringify({
version: 1,
cwd: tempDir,
providerId: 'codex',
createdAt: Date.now(),
}),
'utf8'
);
await fs.writeFile(
path.join(teamDir, 'members.meta.json'),
JSON.stringify({
version: 1,
members: [
{ name: 'alice', providerId: 'codex', role: 'reviewer' },
{ name: 'tom', providerId: 'opencode', role: 'developer' },
],
}),
'utf8'
);
await fs.writeFile(path.join(teamDir, 'inboxes', 'alice.json'), '{}', 'utf8');
const reader = new TeamConfigReader();
const teams = await reader.listTeams();
expect(teams).toHaveLength(1);
expect(teams[0]).toMatchObject({
teamName,
displayName: 'Mixed Aware Team',
memberCount: 2,
});
expect(teams[0]?.partialLaunchFailure).toBeUndefined();
expect(teams[0]?.teamLaunchState).toBeUndefined();
expect(teams[0]?.missingMembers).toBeUndefined();
});
it('exposes lead summary fields without adding lead to teammate member chips', async () => {
const teamName = 'lead-summary-team';
const teamDir = path.join(tempDir, teamName);
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
path.join(teamDir, 'config.json'),
JSON.stringify({
name: 'Lead Summary Team',
members: [
{ name: 'captain', agentType: 'team-lead', color: '#123456' },
{ name: 'alice', role: 'reviewer', color: '#abcdef' },
],
}),
'utf8'
);
const reader = new TeamConfigReader();
const teams = await reader.listTeams();
expect(teams).toHaveLength(1);
expect(teams[0]).toMatchObject({
teamName,
displayName: 'Lead Summary Team',
memberCount: 1,
members: [{ name: 'alice', role: 'reviewer', color: '#abcdef' }],
leadName: 'captain',
leadColor: '#123456',
});
});
it('dedupes and briefly caches listTeams scans until invalidated', async () => {
const teamName = 'cached-list-team';
const teamDir = path.join(tempDir, teamName);
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
path.join(teamDir, 'config.json'),
JSON.stringify({
name: 'Cached List Team',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
const readdirSpy = vi.spyOn(nodeFs.promises, 'readdir');
const reader = new TeamConfigReader();
const [first, second] = await Promise.all([reader.listTeams(), reader.listTeams()]);
const readdirAfterFirstBatch = readdirSpy.mock.calls.length;
expect(first).toHaveLength(1);
expect(second).toHaveLength(1);
await reader.listTeams();
expect(readdirSpy).toHaveBeenCalledTimes(readdirAfterFirstBatch);
TeamConfigReader.invalidateTeam(teamName);
await reader.listTeams();
expect(readdirSpy.mock.calls.length).toBeGreaterThan(readdirAfterFirstBatch);
});
it('does not reuse a stale in-flight listTeams scan after invalidation', async () => {
const teamName = 'inflight-invalidated-list-team';
const teamDir = path.join(tempDir, teamName);
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
path.join(teamDir, 'config.json'),
JSON.stringify({
name: 'Before Invalidation',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
const firstReadStarted = createDeferred<void>();
const releaseFirstRead = createDeferred<void>();
const originalReaddir = nodeFs.promises.readdir.bind(nodeFs.promises);
let blockedFirstTeamScan = false;
const readdirSpy = vi
.spyOn(nodeFs.promises, 'readdir')
.mockImplementation(async (...args: unknown[]) => {
if (!blockedFirstTeamScan && args[0] === tempDir) {
blockedFirstTeamScan = true;
firstReadStarted.resolve();
await releaseFirstRead.promise;
}
return originalReaddir(...(args as Parameters<typeof nodeFs.promises.readdir>));
});
const reader = new TeamConfigReader();
const first = reader.listTeams();
await firstReadStarted.promise;
await fs.writeFile(
path.join(teamDir, 'config.json'),
JSON.stringify({
name: 'After Invalidation',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
TeamConfigReader.invalidateTeam(teamName);
const second = reader.listTeams();
await Promise.resolve();
const teamDirReads = readdirSpy.mock.calls.filter((call) => call[0] === tempDir);
expect(teamDirReads.length).toBeGreaterThanOrEqual(2);
releaseFirstRead.resolve();
const [, secondTeams] = await Promise.all([first, second]);
expect(secondTeams[0]?.displayName).toBe('After Invalidation');
});
it('does not let a removed base member hide an active auto-suffixed teammate in team summaries', async () => {
const teamName = 'suffix-team';
const teamDir = path.join(tempDir, teamName);
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
path.join(teamDir, 'config.json'),
JSON.stringify({
name: 'Suffix Team',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
await fs.writeFile(
path.join(teamDir, 'members.meta.json'),
JSON.stringify({
version: 1,
members: [
{ name: 'alice', role: 'developer', removedAt: Date.now() - 60_000 },
{ name: 'alice-2', role: 'reviewer' },
],
}),
'utf8'
);
const reader = new TeamConfigReader();
const teams = await reader.listTeams();
expect(teams).toHaveLength(1);
expect(teams[0]).toMatchObject({
teamName,
displayName: 'Suffix Team',
memberCount: 1,
members: [{ name: 'alice-2', role: 'reviewer' }],
});
});
it('counts only active non-lead teammates for draft team summaries', async () => {
const teamName = 'draft-summary-team';
const teamDir = path.join(tempDir, teamName);
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
path.join(teamDir, 'team.meta.json'),
JSON.stringify({
version: 1,
cwd: tempDir,
displayName: 'Draft Summary Team',
createdAt: Date.parse('2026-04-22T12:00:00.000Z'),
}),
'utf8'
);
await fs.writeFile(
path.join(teamDir, 'members.meta.json'),
JSON.stringify({
version: 1,
members: [
{ name: 'team-lead', agentType: 'team-lead', color: '#123456' },
{ name: 'alice', removedAt: Date.now() - 60_000 },
{ name: 'bob', role: 'developer' },
],
}),
'utf8'
);
const reader = new TeamConfigReader();
const teams = await reader.listTeams();
expect(teams).toHaveLength(1);
expect(teams[0]).toMatchObject({
teamName,
displayName: 'Draft Summary Team',
memberCount: 1,
leadName: 'team-lead',
leadColor: '#123456',
pendingCreate: true,
});
});
it('uses injected members meta store for draft team summaries', async () => {
const teamName = 'draft-summary-injected-store-team';
const teamDir = path.join(tempDir, teamName);
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
path.join(teamDir, 'team.meta.json'),
JSON.stringify({
version: 1,
cwd: tempDir,
displayName: 'Injected Draft Team',
createdAt: Date.parse('2026-04-22T12:00:00.000Z'),
}),
'utf8'
);
await fs.writeFile(
path.join(teamDir, 'members.meta.json'),
JSON.stringify({ version: 1, members: [] }),
'utf8'
);
const getMembers = vi.fn(async () => [
{ name: 'captain', agentType: 'team-lead', color: '#123456' },
{ name: 'alice', role: 'developer' },
]);
const reader = new TeamConfigReader({ getMembers } as never);
const teams = await reader.listTeams();
expect(getMembers).toHaveBeenCalledWith(teamName);
expect(teams[0]).toMatchObject({
teamName,
displayName: 'Injected Draft Team',
memberCount: 1,
leadName: 'captain',
leadColor: '#123456',
pendingCreate: true,
});
});
it('shares in-flight verified reads without reusing completed cache', async () => {
const teamName = 'cached-config-team';
const teamDir = path.join(tempDir, teamName);
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
path.join(teamDir, 'config.json'),
JSON.stringify({
name: 'Cached Config Team',
projectPath: tempDir,
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile');
const reader = new TeamConfigReader();
const [first, second] = await Promise.all([
reader.getConfigVerified(teamName),
reader.getConfigVerified(teamName),
]);
if (!first) {
throw new Error('Expected config to load.');
}
first.name = 'Mutated In Caller';
const third = await reader.getConfigVerified(teamName);
expect(second?.name).toBe('Cached Config Team');
expect(third?.name).toBe('Cached Config Team');
expect(readFileSpy).toHaveBeenCalledTimes(2);
});
it('uses fingerprint-validated snapshot cache without rereading unchanged config content', async () => {
const teamName = 'snapshot-cache-team';
const teamDir = path.join(tempDir, teamName);
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
path.join(teamDir, 'config.json'),
JSON.stringify({
name: 'Snapshot Cache Team',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile');
const statSpy = vi.spyOn(nodeFs.promises, 'stat');
const reader = new TeamConfigReader();
const first = await reader.getConfigSnapshot(teamName);
if (!first) {
throw new Error('Expected config to load.');
}
first.name = 'Mutated In Caller';
const second = await reader.getConfigSnapshot(teamName);
expect(second?.name).toBe('Snapshot Cache Team');
expect(statSpy).toHaveBeenCalledTimes(2);
expect(readFileSpy).toHaveBeenCalledTimes(1);
});
it('logs slow config reads with mode, likely cause, generation, and caller diagnostics', async () => {
const teamName = 'slow-read-diagnostics-team';
const teamDir = path.join(tempDir, teamName);
const configPath = path.join(teamDir, 'config.json');
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({
name: 'Slow Diagnostics Team',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
vi.spyOn(performance, 'now')
.mockReturnValueOnce(0)
.mockReturnValueOnce(0)
.mockReturnValueOnce(1)
.mockReturnValueOnce(1)
.mockReturnValueOnce(2_001)
.mockReturnValueOnce(2_001)
.mockReturnValueOnce(2_001)
.mockReturnValueOnce(2_001)
.mockReturnValueOnce(2_001);
const reader = new TeamConfigReader();
expect((await reader.getConfigVerified(teamName))?.name).toBe('Slow Diagnostics Team');
const slowLog = warnSpy.mock.calls.find((call) =>
String(call[1] ?? '').includes('[getConfig] slow read diag=')
);
expect(slowLog).toBeTruthy();
const rawMessage = String(slowLog?.[1] ?? '');
const diag = JSON.parse(rawMessage.slice(rawMessage.indexOf('diag=') + 'diag='.length)) as {
mode: string;
configPath: string;
likelyCause: string;
readMs: number;
cacheGeneration: number;
currentGeneration: number;
caller: string | null;
};
expect(diag).toMatchObject({
mode: 'verified',
configPath,
likelyCause: 'io_read_slow',
readMs: 2000,
cacheGeneration: 0,
currentGeneration: 0,
});
expect(diag.caller).toBeTruthy();
});
it('shares in-flight snapshot stat and read work for concurrent calls', async () => {
const teamName = 'snapshot-inflight-team';
const teamDir = path.join(tempDir, teamName);
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
path.join(teamDir, 'config.json'),
JSON.stringify({
name: 'Snapshot Inflight Team',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile');
const statSpy = vi.spyOn(nodeFs.promises, 'stat');
const reader = new TeamConfigReader();
const [first, second] = await Promise.all([
reader.getConfigSnapshot(teamName),
reader.getConfigSnapshot(teamName),
]);
expect(first?.name).toBe('Snapshot Inflight Team');
expect(second?.name).toBe('Snapshot Inflight Team');
expect(statSpy).toHaveBeenCalledTimes(1);
expect(readFileSpy).toHaveBeenCalledTimes(1);
});
it('rereads snapshot when ctime changes even if mtime is unchanged', async () => {
const teamName = 'snapshot-ctime-team';
const teamDir = path.join(tempDir, teamName);
const configPath = path.join(teamDir, 'config.json');
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({
name: 'Before Ctime',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
let ctimeMs = 1000;
vi.spyOn(nodeFs.promises, 'stat').mockImplementation(
async () =>
({
size: BigInt(4096),
mode: BigInt(33188),
dev: BigInt(1),
ino: BigInt(2),
mtimeMs: 1000,
ctimeMs,
birthtimeMs: 1000,
isFile: () => true,
}) as never
);
const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile');
const reader = new TeamConfigReader();
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Before Ctime');
await fs.writeFile(
configPath,
JSON.stringify({
name: 'After Ctime',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
ctimeMs = 2000;
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('After Ctime');
expect(readFileSpy).toHaveBeenCalledTimes(2);
});
it('rereads snapshot when the config fingerprint changes', async () => {
const teamName = 'snapshot-reread-team';
const teamDir = path.join(tempDir, teamName);
const configPath = path.join(teamDir, 'config.json');
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({
name: 'Before',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile');
const reader = new TeamConfigReader();
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Before');
await fs.writeFile(
configPath,
JSON.stringify({
name: 'After',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('After');
expect(readFileSpy).toHaveBeenCalledTimes(2);
});
it('primeConfig updates snapshot cache immediately after app-owned writes', async () => {
const teamName = 'prime-cache-team';
const teamDir = path.join(tempDir, teamName);
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
path.join(teamDir, 'config.json'),
JSON.stringify({
name: 'Before Prime',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
const reader = new TeamConfigReader();
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Before Prime');
await fs.writeFile(
path.join(teamDir, 'config.json'),
JSON.stringify({
name: 'After Prime',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
await TeamConfigReader.primeConfig(teamName, {
name: 'After Prime',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
} as never);
const snapshot = await reader.getConfigSnapshot(teamName);
expect(snapshot?.name).toBe('After Prime');
});
it('does not let stale in-flight snapshot reads overwrite a primed config cache', async () => {
const teamName = 'stale-read-prime-team';
const teamDir = path.join(tempDir, teamName);
const configPath = path.join(teamDir, 'config.json');
await fs.mkdir(teamDir, { recursive: true });
const staleRaw = JSON.stringify({
name: 'Stale Read',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
});
await fs.writeFile(configPath, staleRaw, 'utf8');
const readDeferred = createDeferred<string>();
const realReadFile = nodeFs.promises.readFile.bind(nodeFs.promises);
let intercepted = false;
vi.spyOn(nodeFs.promises, 'readFile').mockImplementation(((
file: unknown,
...args: unknown[]
) => {
if (!intercepted && String(file) === configPath) {
intercepted = true;
return readDeferred.promise as never;
}
return realReadFile(file as never, ...(args as never[])) as never;
}) as never);
const reader = new TeamConfigReader();
const staleSnapshot = reader.getConfigSnapshot(teamName);
await vi.waitFor(() => expect(intercepted).toBe(true));
await fs.writeFile(
configPath,
JSON.stringify({
name: 'Fresh Prime',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
await TeamConfigReader.primeConfig(teamName, {
name: 'Fresh Prime',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
} as never);
readDeferred.resolve(staleRaw);
expect((await staleSnapshot)?.name).toBe('Stale Read');
vi.spyOn(nodeFs.promises, 'stat').mockRejectedValue(new Error('stat unavailable'));
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Fresh Prime');
});
it('does not reuse stale in-flight verified reads after app-owned primeConfig', async () => {
const teamName = 'verified-stale-read-prime-team';
const teamDir = path.join(tempDir, teamName);
const configPath = path.join(teamDir, 'config.json');
await fs.mkdir(teamDir, { recursive: true });
const staleRaw = JSON.stringify({
name: 'Stale Verified Read',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
});
await fs.writeFile(configPath, staleRaw, 'utf8');
const readDeferred = createDeferred<string>();
const realReadFile = nodeFs.promises.readFile.bind(nodeFs.promises);
let intercepted = false;
vi.spyOn(nodeFs.promises, 'readFile').mockImplementation(((
file: unknown,
...args: unknown[]
) => {
if (!intercepted && String(file) === configPath) {
intercepted = true;
return readDeferred.promise as never;
}
return realReadFile(file as never, ...(args as never[])) as never;
}) as never);
const reader = new TeamConfigReader();
const staleVerified = reader.getConfig(teamName);
await vi.waitFor(() => expect(intercepted).toBe(true));
await fs.writeFile(
configPath,
JSON.stringify({
name: 'Fresh Verified Prime',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
await TeamConfigReader.primeConfig(teamName, {
name: 'Fresh Verified Prime',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
} as never);
expect((await reader.getConfig(teamName))?.name).toBe('Fresh Verified Prime');
readDeferred.resolve(staleRaw);
expect((await staleVerified)?.name).toBe('Stale Verified Read');
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Fresh Verified Prime');
});
it('does not let stale in-flight snapshot read failures invalidate a primed config cache', async () => {
const teamName = 'stale-read-failure-prime-team';
const teamDir = path.join(tempDir, teamName);
const configPath = path.join(teamDir, 'config.json');
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({
name: 'Before Failure',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
const readDeferred = createDeferred<string>();
const realReadFile = nodeFs.promises.readFile.bind(nodeFs.promises);
let intercepted = false;
vi.spyOn(nodeFs.promises, 'readFile').mockImplementation(((
file: unknown,
...args: unknown[]
) => {
if (!intercepted && String(file) === configPath) {
intercepted = true;
return readDeferred.promise as never;
}
return realReadFile(file as never, ...(args as never[])) as never;
}) as never);
const reader = new TeamConfigReader();
const staleSnapshot = reader.getConfigSnapshot(teamName);
await vi.waitFor(() => expect(intercepted).toBe(true));
await fs.writeFile(
configPath,
JSON.stringify({
name: 'Fresh After Failure',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
await TeamConfigReader.primeConfig(teamName, {
name: 'Fresh After Failure',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
} as never);
readDeferred.reject(new Error('old read failed'));
await expect(staleSnapshot).resolves.toBeNull();
vi.spyOn(nodeFs.promises, 'stat').mockRejectedValue(new Error('stat unavailable'));
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Fresh After Failure');
});
it('does not let stale in-flight snapshot stat results invalidate a primed config cache', async () => {
const teamName = 'stale-stat-prime-team';
const teamDir = path.join(tempDir, teamName);
const configPath = path.join(teamDir, 'config.json');
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({
name: 'Before Stat Race',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
const statDeferred = createDeferred<unknown>();
let statCalls = 0;
vi.spyOn(nodeFs.promises, 'stat').mockImplementation(async () => {
statCalls++;
if (statCalls === 1) {
return (await statDeferred.promise) as never;
}
throw new Error('stat unavailable');
});
const reader = new TeamConfigReader();
const snapshot = reader.getConfigSnapshot(teamName);
await vi.waitFor(() => expect(statCalls).toBe(1));
await fs.writeFile(
configPath,
JSON.stringify({
name: 'Fresh After Stat Race',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
await TeamConfigReader.primeConfig(teamName, {
name: 'Fresh After Stat Race',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
} as never);
statDeferred.resolve({
size: BigInt(4096),
mode: BigInt(33188),
dev: BigInt(1),
ino: BigInt(2),
mtimeMs: 1000,
ctimeMs: 1000,
birthtimeMs: 1000,
isFile: () => false,
});
expect((await snapshot)?.name).toBe('Fresh After Stat Race');
});
it('invalidateTeam forces the next snapshot to reread config content', async () => {
const teamName = 'invalidate-cache-team';
const teamDir = path.join(tempDir, teamName);
const configPath = path.join(teamDir, 'config.json');
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({
name: 'Before Invalidate',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile');
const reader = new TeamConfigReader();
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Before Invalidate');
await fs.writeFile(
configPath,
JSON.stringify({
name: 'After Invalidate',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
TeamConfigReader.invalidateTeam(teamName);
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('After Invalidate');
expect(readFileSpy).toHaveBeenCalledTimes(2);
});
it('uses recent snapshot cache on stat failure but verified mode does not', async () => {
const teamName = 'stat-failure-team';
const teamDir = path.join(tempDir, teamName);
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
path.join(teamDir, 'config.json'),
JSON.stringify({
name: 'Recent Cache',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
const reader = new TeamConfigReader();
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Recent Cache');
vi.spyOn(nodeFs.promises, 'stat').mockRejectedValue(new Error('stat unavailable'));
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Recent Cache');
await expect(reader.getConfigVerified(teamName)).resolves.toBeNull();
});
it('clears snapshot cache after parse failure', async () => {
const teamName = 'parse-failure-team';
const teamDir = path.join(tempDir, teamName);
const configPath = path.join(teamDir, 'config.json');
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({
name: 'Valid Config',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
const reader = new TeamConfigReader();
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Valid Config');
await fs.writeFile(configPath, '{"name":', 'utf8');
expect(await reader.getConfigSnapshot(teamName)).toBeNull();
await fs.rm(configPath);
expect(await reader.getConfigSnapshot(teamName)).toBeNull();
});
it('clears snapshot cache when config disappears and reloads after recreation', async () => {
const teamName = 'missing-then-recreated-team';
const teamDir = path.join(tempDir, teamName);
const configPath = path.join(teamDir, 'config.json');
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({
name: 'Before Delete',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
const reader = new TeamConfigReader();
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Before Delete');
await fs.rm(configPath);
expect(await reader.getConfigSnapshot(teamName)).toBeNull();
await fs.writeFile(
configPath,
JSON.stringify({
name: 'After Recreate',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('After Recreate');
});
it('bounds stale snapshots on coarse fingerprints with periodic full verification', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-30T12:00:00.000Z'));
const teamName = 'coarse-fs-team';
const teamDir = path.join(tempDir, teamName);
const configPath = path.join(teamDir, 'config.json');
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({
name: 'Alpha',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile');
vi.spyOn(nodeFs.promises, 'stat').mockResolvedValue({
size: BigInt(4096),
mode: BigInt(33188),
dev: BigInt(1),
ino: BigInt(2),
mtimeMs: 1000,
ctimeMs: 1000,
birthtimeMs: 1000,
isFile: () => true,
} as never);
const reader = new TeamConfigReader();
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Alpha');
await fs.writeFile(
configPath,
JSON.stringify({
name: 'Bravo',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}),
'utf8'
);
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Alpha');
vi.advanceTimersByTime(1_501);
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Bravo');
expect(readFileSpy).toHaveBeenCalledTimes(2);
});
});