perf(team): cache team data reads safely

This commit is contained in:
777genius 2026-05-02 20:24:46 +03:00
parent 9ad32d9978
commit 4385b0c679
31 changed files with 1841 additions and 178 deletions

View file

@ -22,7 +22,7 @@ import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
import type { TeamMember } from '@shared/types'; import type { TeamMember } from '@shared/types';
export interface TeamTaskAgendaSourceDeps { export interface TeamTaskAgendaSourceDeps {
configReader: TeamConfigReader; configReader: Pick<TeamConfigReader, 'getConfig'>;
taskReader: TeamTaskReader; taskReader: TeamTaskReader;
kanbanManager: TeamKanbanManager; kanbanManager: TeamKanbanManager;
membersMetaStore: TeamMembersMetaStore; membersMetaStore: TeamMembersMetaStore;

View file

@ -124,8 +124,18 @@ export function createMemberWorkSyncFeature(deps: {
}): MemberWorkSyncFeatureFacade { }): MemberWorkSyncFeatureFacade {
const clock = new SystemClockAdapter(); const clock = new SystemClockAdapter();
const hash = new NodeHashAdapter(); const hash = new NodeHashAdapter();
const configReaderForReadOnlySync = {
listTeams: () =>
typeof deps.configReader.listTeams === 'function'
? deps.configReader.listTeams()
: Promise.resolve([]),
getConfig: (teamName: string) =>
typeof deps.configReader.getConfigSnapshot === 'function'
? deps.configReader.getConfigSnapshot(teamName)
: deps.configReader.getConfig(teamName),
};
const agendaSource = new TeamTaskAgendaSource({ const agendaSource = new TeamTaskAgendaSource({
configReader: deps.configReader, configReader: configReaderForReadOnlySync,
taskReader: deps.taskReader, taskReader: deps.taskReader,
kanbanManager: deps.kanbanManager, kanbanManager: deps.kanbanManager,
membersMetaStore: deps.membersMetaStore, membersMetaStore: deps.membersMetaStore,
@ -150,7 +160,7 @@ export function createMemberWorkSyncFeature(deps: {
const runtimeTurnSettledTargetResolver = const runtimeTurnSettledTargetResolver =
deps.runtimeTurnSettledTargetResolver ?? deps.runtimeTurnSettledTargetResolver ??
new TeamRuntimeTurnSettledTargetResolver({ new TeamRuntimeTurnSettledTargetResolver({
teamSource: deps.configReader, teamSource: configReaderForReadOnlySync,
membersMetaStore: deps.membersMetaStore, membersMetaStore: deps.membersMetaStore,
}); });
const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths); const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths);

View file

@ -67,6 +67,7 @@ import {
resolveAgentTeamsMcpLaunchSpec, resolveAgentTeamsMcpLaunchSpec,
TeamMcpConfigBuilder, TeamMcpConfigBuilder,
} from '@main/services/team/TeamMcpConfigBuilder'; } from '@main/services/team/TeamMcpConfigBuilder';
import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver';
import { killTrackedCliProcesses } from '@main/utils/childProcess'; import { killTrackedCliProcesses } from '@main/utils/childProcess';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { import {
@ -609,6 +610,8 @@ let shutdownComplete = false;
const startupTimers = new Set<ReturnType<typeof setTimeout>>(); const startupTimers = new Set<ReturnType<typeof setTimeout>>();
const SHUTDOWN_STEP_TIMEOUT_MS = 5_000; const SHUTDOWN_STEP_TIMEOUT_MS = 5_000;
const STARTUP_RECOVERY_DELAY_MS = 10_000;
const STARTUP_RECOVERY_CONCURRENCY = 1;
function isShutdownStarted(): boolean { function isShutdownStarted(): boolean {
return shutdownComplete || shutdownPromise !== null; return shutdownComplete || shutdownPromise !== null;
@ -626,6 +629,23 @@ function scheduleStartupTask(action: () => void, delayMs: number): void {
startupTimers.add(timer); startupTimers.add(timer);
} }
async function runStartupJobsBounded<T>(
items: readonly T[],
concurrency: number,
run: (item: T) => Promise<void>
): Promise<void> {
const workerCount = Math.max(1, Math.min(concurrency, items.length));
const workers = Array.from({ length: workerCount }, async (_, workerIndex) => {
for (let index = workerIndex; index < items.length; index += workerCount) {
if (isShutdownStarted()) {
return;
}
await run(items[index]!);
}
});
await Promise.allSettled(workers);
}
function clearStartupTimers(): void { function clearStartupTimers(): void {
for (const timer of startupTimers) { for (const timer of startupTimers) {
clearTimeout(timer); clearTimeout(timer);
@ -808,9 +828,18 @@ function wireFileWatcherEvents(context: ServiceContext): void {
const detail = typeof row.detail === 'string' ? row.detail : ''; const detail = typeof row.detail === 'string' ? row.detail : '';
memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent); memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent);
if (row.type === 'config' && detail === 'config.json') { if (row.type === 'config') {
TeamConfigReader.invalidateTeam(teamName); if (detail === 'config.json') {
getTeamDataWorkerClient().invalidateTeamConfig(teamName); TeamConfigReader.invalidateTeam(teamName);
getTeamDataWorkerClient().invalidateTeamConfig(teamName);
} else if (detail === 'team.meta.json' || detail === 'members.meta.json') {
TeamConfigReader.invalidateListTeamsCache();
getTeamDataWorkerClient().invalidateTeamConfig(teamName);
}
}
if (row.type === 'task') {
TeamTaskReader.invalidateAllTasksCache();
} }
if ( if (
@ -818,6 +847,9 @@ function wireFileWatcherEvents(context: ServiceContext): void {
(row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config') (row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config')
) { ) {
teamDataService.invalidateMessageFeed(teamName); teamDataService.invalidateMessageFeed(teamName);
if (row.type === 'inbox' || row.type === 'lead-message') {
getTeamDataWorkerClient().invalidateTeamMessageFeed(teamName);
}
} }
// --- Inbox change events: relay to lead + native OS notifications --- // --- Inbox change events: relay to lead + native OS notifications ---
@ -1060,7 +1092,12 @@ async function initializeServices(): Promise<void> {
ptyTerminalService = new PtyTerminalService(); ptyTerminalService = new PtyTerminalService();
const teamMemberLogsFinder = new TeamMemberLogsFinder(); const teamMemberLogsFinder = new TeamMemberLogsFinder();
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder); const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
const teamTranscriptSourceLocator = new TeamTranscriptSourceLocator(); const taskLogConfigReader = new TeamConfigReader();
const teamTranscriptSourceLocator = new TeamTranscriptSourceLocator(
new TeamTranscriptProjectResolver({
getConfig: (teamName) => taskLogConfigReader.getConfigSnapshot(teamName),
})
);
teamLogSourceTracker.onLogSourceChange((teamName) => { teamLogSourceTracker.onLogSourceChange((teamName) => {
teamTranscriptSourceLocator.invalidateTeam(teamName); teamTranscriptSourceLocator.invalidateTeam(teamName);
}); });
@ -1203,15 +1240,26 @@ async function initializeServices(): Promise<void> {
}); });
const forwardTeamChange = (event: TeamChangeEvent): void => { const forwardTeamChange = (event: TeamChangeEvent): void => {
if (event.type === 'config' && event.detail === 'config.json') { if (event.type === 'config') {
TeamConfigReader.invalidateTeam(event.teamName); if (event.detail === 'config.json') {
getTeamDataWorkerClient().invalidateTeamConfig(event.teamName); TeamConfigReader.invalidateTeam(event.teamName);
getTeamDataWorkerClient().invalidateTeamConfig(event.teamName);
} else if (event.detail === 'team.meta.json' || event.detail === 'members.meta.json') {
TeamConfigReader.invalidateListTeamsCache();
getTeamDataWorkerClient().invalidateTeamConfig(event.teamName);
}
}
if (event.type === 'task') {
TeamTaskReader.invalidateAllTasksCache();
} }
if ( if (
teamDataService && teamDataService &&
(event.type === 'inbox' || event.type === 'lead-message' || event.type === 'config') (event.type === 'inbox' || event.type === 'lead-message' || event.type === 'config')
) { ) {
teamDataService.invalidateMessageFeed(event.teamName); teamDataService.invalidateMessageFeed(event.teamName);
if (event.type === 'inbox' || event.type === 'lead-message') {
getTeamDataWorkerClient().invalidateTeamMessageFeed(event.teamName);
}
} }
safeSendToRenderer(mainWindow, TEAM_CHANGE, event); safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
httpServer?.broadcast('team-change', event); httpServer?.broadcast('team-change', event);
@ -1235,18 +1283,25 @@ async function initializeServices(): Promise<void> {
teamLogSourceTracker.onLogSourceChange((teamName) => { teamLogSourceTracker.onLogSourceChange((teamName) => {
teammateToolTracker?.handleLogSourceChange(teamName); teammateToolTracker?.handleLogSourceChange(teamName);
}); });
void teamDataService scheduleStartupTask(() => {
.listTeams() void teamDataService
.then(async (teams) => { .listTeams()
await Promise.all( .then(async (teams) => {
teams.map((team) => const activeTeamNames = teams
teamProvisioningService.scanOpenCodePromptDeliveryWatchdog(team.teamName) .filter((team) => !team.deletedAt)
) .map((team) => team.teamName);
await runStartupJobsBounded(
activeTeamNames,
STARTUP_RECOVERY_CONCURRENCY,
async (teamName) => {
await teamProvisioningService.scanOpenCodePromptDeliveryWatchdog(teamName);
}
);
})
.catch((error: unknown) =>
logger.warn(`[Init] OpenCode prompt delivery watchdog recovery failed: ${String(error)}`)
); );
}) }, STARTUP_RECOVERY_DELAY_MS);
.catch((error: unknown) =>
logger.warn(`[Init] OpenCode prompt delivery watchdog recovery failed: ${String(error)}`)
);
teamTaskStallMonitor.start(); teamTaskStallMonitor.start();
// Allow SchedulerService to push schedule events to renderer // Allow SchedulerService to push schedule events to renderer
@ -1301,16 +1356,25 @@ async function initializeServices(): Promise<void> {
? memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment(input) ? memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment(input)
: Promise.resolve(null) : Promise.resolve(null)
); );
void teamDataService scheduleStartupTask(() => {
.listTeams() void teamDataService
.then(async (teams) => { .listTeams()
const activeTeamNames = teams.filter((team) => !team.deletedAt).map((team) => team.teamName); .then(async (teams) => {
await memberWorkSyncFeature?.replayPendingReports(activeTeamNames); const lifecycleActiveTeamNames = teams
await memberWorkSyncFeature?.enqueueStartupScan(activeTeamNames); .filter(
}) (team) =>
.catch((error: unknown) => !team.deletedAt &&
logger.warn(`[Init] Member work sync startup scan failed: ${String(error)}`) (teamProvisioningService.isTeamAlive(team.teamName) ||
); teamProvisioningService.hasProvisioningRun(team.teamName))
)
.map((team) => team.teamName);
await memberWorkSyncFeature?.replayPendingReports(lifecycleActiveTeamNames);
await memberWorkSyncFeature?.enqueueStartupScan(lifecycleActiveTeamNames);
})
.catch((error: unknown) =>
logger.warn(`[Init] Member work sync startup scan failed: ${String(error)}`)
);
}, STARTUP_RECOVERY_DELAY_MS + 2_000);
codexAccountFeature = createCodexAccountFeature({ codexAccountFeature = createCodexAccountFeature({
logger: createLogger('Feature:CodexAccount'), logger: createLogger('Feature:CodexAccount'),
configManager, configManager,

View file

@ -220,6 +220,7 @@ import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser';
const logger = createLogger('IPC:teams'); const logger = createLogger('IPC:teams');
const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 12_000; const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 12_000;
const TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS = 250;
/** /**
* In-memory set of rate-limit message keys already processed. * In-memory set of rate-limit message keys already processed.
@ -916,35 +917,53 @@ async function handleGetData(
const tn = validated.value!; const tn = validated.value!;
const startedAt = Date.now(); const startedAt = Date.now();
let data: TeamViewSnapshot; let data: TeamViewSnapshot;
let dataSource: 'worker' | 'main-fallback' | 'main-unavailable' = 'main-unavailable';
let workerAvailable = false;
setCurrentMainOp('team:getData'); setCurrentMainOp('team:getData');
try { try {
// Prefer worker thread to keep main event loop responsive // Prefer worker thread to keep main event loop responsive
const worker = getTeamDataWorkerClient(); const worker = getTeamDataWorkerClient();
if (worker.isAvailable()) { workerAvailable = worker.isAvailable();
const missingState = await classifyMissingTeamData(tn);
if (missingState === 'provisioning') {
return { success: false, error: 'TEAM_PROVISIONING' };
}
if (missingState === 'draft') {
return { success: false, error: 'TEAM_DRAFT' };
}
if (workerAvailable) {
try { try {
data = await worker.getTeamData(tn); data = await worker.getTeamData(tn);
dataSource = 'worker';
} catch (workerErr) { } catch (workerErr) {
logger.warn( logger.warn(
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}` `[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
); );
noteHeavyTeamDataWorkerFallback('teams:getData'); noteHeavyTeamDataWorkerFallback('teams:getData');
data = await getTeamDataService().getTeamData(tn); data = await getTeamDataService().getTeamData(tn);
dataSource = 'main-fallback';
} }
} else { } else {
noteHeavyTeamDataWorkerFallback('teams:getData'); noteHeavyTeamDataWorkerFallback('teams:getData');
data = await getTeamDataService().getTeamData(tn); data = await getTeamDataService().getTeamData(tn);
dataSource = 'main-unavailable';
} }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if ( if (
message === `Team not found: ${tn}` && message === `Team not found: ${tn}` &&
getTeamProvisioningService().hasProvisioningRun(tn) getTeamProvisioningService().hasProvisioningRun?.(tn) === true
) { ) {
return { success: false, error: 'TEAM_PROVISIONING' }; return { success: false, error: 'TEAM_PROVISIONING' };
} }
// Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate) // Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate)
if (message === `Team not found: ${tn}`) { if (message === `Team not found: ${tn}`) {
const meta = await teamMetaStore.getMeta(tn); const meta = await withTimeoutValue(
teamMetaStore.getMeta(tn).catch(() => null),
TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS,
null
);
if (meta) { if (meta) {
return { success: false, error: 'TEAM_DRAFT' }; return { success: false, error: 'TEAM_DRAFT' };
} }
@ -957,7 +976,9 @@ async function handleGetData(
const getDataMs = Date.now() - startedAt; const getDataMs = Date.now() - startedAt;
if (getDataMs >= 1500) { if (getDataMs >= 1500) {
logger.warn(`[teams:getData] slow team=${tn} ms=${getDataMs}`); logger.warn(
`[teams:getData] slow team=${tn} ms=${getDataMs} source=${dataSource} workerAvailable=${workerAvailable}`
);
} }
const teamDataService = getTeamDataService(); const teamDataService = getTeamDataService();
if (data.processes.some((process) => !process.stoppedAt)) { if (data.processes.some((process) => !process.stoppedAt)) {
@ -1015,6 +1036,33 @@ async function handleGetData(
return { success: true, data: { ...data, isAlive } }; return { success: true, data: { ...data, isAlive } };
} }
async function classifyMissingTeamData(teamName: string): Promise<'provisioning' | 'draft' | null> {
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
const configExists = await withTimeoutValue(
fs.promises
.access(configPath, fs.constants.F_OK)
.then(() => true)
.catch((error: unknown) => {
const code = typeof error === 'object' && error ? (error as { code?: unknown }).code : null;
return code === 'ENOENT' ? false : null;
}),
TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS,
null
);
if (configExists !== false) {
return null;
}
if (getTeamProvisioningService().hasProvisioningRun?.(teamName) === true) {
return 'provisioning';
}
const meta = await withTimeoutValue(
teamMetaStore.getMeta(teamName).catch(() => null),
TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS,
null
);
return meta ? 'draft' : null;
}
async function handleGetTaskChangePresence( async function handleGetTaskChangePresence(
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
teamName: unknown teamName: unknown
@ -1116,6 +1164,7 @@ async function handleDeleteTeam(
getAutoResumeService().cancelPendingAutoResume(validated.value!); getAutoResumeService().cancelPendingAutoResume(validated.value!);
await getTeamProvisioningService().stopTeam(validated.value!); await getTeamProvisioningService().stopTeam(validated.value!);
await getTeamDataService().deleteTeam(validated.value!); await getTeamDataService().deleteTeam(validated.value!);
getTeamDataWorkerClient().invalidateTeamConfig(validated.value!);
}); });
} }
@ -1127,7 +1176,10 @@ async function handleRestoreTeam(
if (!validated.valid) { if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' }; return { success: false, error: validated.error ?? 'Invalid teamName' };
} }
return wrapTeamHandler('restoreTeam', () => getTeamDataService().restoreTeam(validated.value!)); return wrapTeamHandler('restoreTeam', async () => {
await getTeamDataService().restoreTeam(validated.value!);
getTeamDataWorkerClient().invalidateTeamConfig(validated.value!);
});
} }
async function handlePermanentlyDeleteTeam( async function handlePermanentlyDeleteTeam(
@ -1141,6 +1193,7 @@ async function handlePermanentlyDeleteTeam(
return wrapTeamHandler('permanentlyDeleteTeam', async () => { return wrapTeamHandler('permanentlyDeleteTeam', async () => {
getAutoResumeService().cancelPendingAutoResume(validated.value!); getAutoResumeService().cancelPendingAutoResume(validated.value!);
await getTeamDataService().permanentlyDeleteTeam(validated.value!); await getTeamDataService().permanentlyDeleteTeam(validated.value!);
getTeamDataWorkerClient().invalidateTeamConfig(validated.value!);
// Clean up app-owned data (attachments, task-attachments) that lives outside ~/.claude/ // Clean up app-owned data (attachments, task-attachments) that lives outside ~/.claude/
const appData = getAppDataPath(); const appData = getAppDataPath();
await fs.promises await fs.promises
@ -1208,6 +1261,7 @@ async function handleUpdateConfig(
} }
} }
getTeamDataWorkerClient().invalidateTeamConfig(tn);
return result; return result;
}); });
} }
@ -2352,35 +2406,47 @@ async function handleGetMessagesPage(
return wrapTeamHandler('getMessagesPage', async () => { return wrapTeamHandler('getMessagesPage', async () => {
let page: MessagesPage; let page: MessagesPage;
const notificationContext = await getTeamDataService().getTeamNotificationContext(vTeam.value!); const teamName = vTeam.value!;
const scanNotifications = (messagesPage: MessagesPage): void => {
const notificationContextPromise: Promise<{ displayName: string; projectPath?: string }> =
getTeamDataService()
.getTeamNotificationContext(teamName)
.catch(() => ({ displayName: teamName }));
void notificationContextPromise
.then((notificationContext) => {
scanTeamMessageNotifications(
messagesPage.messages,
teamName,
notificationContext.displayName,
notificationContext.projectPath
);
})
.catch((error: unknown) => {
logger.debug(
`[teams:getMessagesPage] notification scan skipped team=${teamName}: ${
error instanceof Error ? error.message : String(error)
}`
);
});
};
const liveMessages = const liveMessages =
cursor == null ? getTeamProvisioningService().getLiveLeadProcessMessages(vTeam.value!) : []; cursor == null ? getTeamProvisioningService().getLiveLeadProcessMessages(teamName) : [];
if (liveMessages.length > 0) { if (liveMessages.length > 0) {
page = await getTeamDataService().getMessagesPage(vTeam.value!, { page = await getTeamDataService().getMessagesPage(teamName, {
cursor, cursor,
limit, limit,
liveMessages, liveMessages,
}); });
scanTeamMessageNotifications( scanNotifications(page);
page.messages,
vTeam.value!,
notificationContext.displayName,
notificationContext.projectPath
);
return page; return page;
} }
const worker = getTeamDataWorkerClient(); const worker = getTeamDataWorkerClient();
if (worker.isAvailable()) { if (worker.isAvailable()) {
try { try {
page = await worker.getMessagesPage(vTeam.value!, { cursor, limit }); page = await worker.getMessagesPage(teamName, { cursor, limit });
scanTeamMessageNotifications( scanNotifications(page);
page.messages,
vTeam.value!,
notificationContext.displayName,
notificationContext.projectPath
);
return page; return page;
} catch (workerErr) { } catch (workerErr) {
logger.warn( logger.warn(
@ -2391,13 +2457,8 @@ async function handleGetMessagesPage(
} }
} }
noteHeavyTeamDataWorkerFallback('teams:getMessagesPage'); noteHeavyTeamDataWorkerFallback('teams:getMessagesPage');
page = await getTeamDataService().getMessagesPage(vTeam.value!, { cursor, limit }); page = await getTeamDataService().getMessagesPage(teamName, { cursor, limit });
scanTeamMessageNotifications( scanNotifications(page);
page.messages,
vTeam.value!,
notificationContext.displayName,
notificationContext.projectPath
);
return page; return page;
}); });
} }
@ -3307,8 +3368,8 @@ async function handleCreateConfig(
}); });
} }
return wrapTeamHandler('createConfig', () => return wrapTeamHandler('createConfig', async () => {
getTeamDataService().createTeamConfig({ await getTeamDataService().createTeamConfig({
teamName, teamName,
displayName: payload.displayName?.trim() || undefined, displayName: payload.displayName?.trim() || undefined,
description: payload.description?.trim() || undefined, description: payload.description?.trim() || undefined,
@ -3332,8 +3393,9 @@ async function handleCreateConfig(
typeof payload.extraCliArgs === 'string' && payload.extraCliArgs.trim() typeof payload.extraCliArgs === 'string' && payload.extraCliArgs.trim()
? payload.extraCliArgs.trim() ? payload.extraCliArgs.trim()
: undefined, : undefined,
}) });
); getTeamDataWorkerClient().invalidateTeamConfig(teamName);
});
} }
function getTeamMemberLogsFinder(): TeamMemberLogsFinder { function getTeamMemberLogsFinder(): TeamMemberLogsFinder {

View file

@ -1008,7 +1008,8 @@ export class FileWatcher extends EventEmitter {
if ( if (
relative === 'config.json' || relative === 'config.json' ||
relative === 'kanban-state.json' || relative === 'kanban-state.json' ||
relative === 'team.meta.json' relative === 'team.meta.json' ||
relative === 'members.meta.json'
) { ) {
const event: TeamChangeEvent = { const event: TeamChangeEvent = {
type: 'config', type: 'config',

View file

@ -1,10 +1,9 @@
import { getClaudeBasePath, getTeamsBasePath } from '@main/utils/pathDecoder'; import { getClaudeBasePath } from '@main/utils/pathDecoder';
import { CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, formatCrossTeamText } from '@shared/constants'; import { CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, formatCrossTeamText } from '@shared/constants';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import * as agentTeamsControllerModule from 'agent-teams-controller'; import * as agentTeamsControllerModule from 'agent-teams-controller';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import * as fs from 'fs';
import { buildActionModeAgentBlock } from './actionModeInstructions'; import { buildActionModeAgentBlock } from './actionModeInstructions';
import { CascadeGuard } from './CascadeGuard'; import { CascadeGuard } from './CascadeGuard';
@ -117,8 +116,11 @@ export class CrossTeamService {
throw new Error(`Target team not found: ${toTeam}`); throw new Error(`Target team not found: ${toTeam}`);
} }
// 2. Resolve lead // 2. Resolve lead. Reuse the verified target config before falling back to meta storage.
const leadName = (await this.dataService.getLeadMemberName(toTeam)) ?? 'team-lead'; const leadName =
targetConfig.members?.find((m) => isLeadMember(m))?.name?.trim() ||
(await this.dataService.getLeadMemberName(toTeam)) ||
'team-lead';
// 3. Format // 3. Format
const from = `${fromTeam}.${fromMember}`; const from = `${fromTeam}.${fromMember}`;
@ -203,39 +205,34 @@ export class CrossTeamService {
} }
async listAvailableTargets(excludeTeam?: string): Promise<CrossTeamTarget[]> { async listAvailableTargets(excludeTeam?: string): Promise<CrossTeamTarget[]> {
const teamsDir = getTeamsBasePath(); let teams: Awaited<ReturnType<TeamDataService['listTeams']>>;
let entries: string[];
try { try {
entries = await fs.promises.readdir(teamsDir); teams = await this.dataService.listTeams();
} catch { } catch {
return []; return [];
} }
const targets: CrossTeamTarget[] = []; const targets: CrossTeamTarget[] = teams
for (const entry of entries) { .filter((team) => {
if (excludeTeam && entry === excludeTeam) continue; if (excludeTeam && team.teamName === excludeTeam) return false;
if (!TEAM_NAME_PATTERN.test(entry)) continue; if (!TEAM_NAME_PATTERN.test(team.teamName)) return false;
return !team.deletedAt && !team.pendingCreate;
let config: TeamConfig | null; })
try { .map((team) => {
config = await this.configReader.getConfig(entry); const summaryLead =
} catch { team.leadName || team.leadColor
continue; ? { name: team.leadName, color: team.leadColor }
} : team.members?.find((member) => isLeadMember(member));
if (!config || config.deletedAt) continue; return {
teamName: team.teamName,
const lead = config.members?.find((m) => isLeadMember(m)); displayName: team.displayName || team.teamName,
description: team.description,
targets.push({ color: team.color,
teamName: entry, ...(summaryLead?.name ? { leadName: summaryLead.name } : {}),
displayName: config.name || entry, ...(summaryLead?.color ? { leadColor: summaryLead.color } : {}),
description: config.description, isOnline: this.provisioning?.isTeamAlive(team.teamName) ?? false,
color: config.color, };
leadName: lead?.name,
leadColor: lead?.color,
isOnline: this.provisioning?.isTeamAlive(entry) ?? false,
}); });
}
return targets.sort((a, b) => { return targets.sort((a, b) => {
if (a.isOnline && !b.isOnline) return -1; if (a.isOnline && !b.isOnline) return -1;

View file

@ -40,6 +40,7 @@ const PER_TEAM_READ_TIMEOUT_MS = 5_000;
const GET_CONFIG_SLOW_READ_WARN_MS = 500; const GET_CONFIG_SLOW_READ_WARN_MS = 500;
const CONFIG_SNAPSHOT_RECENT_STAT_FAILURE_FALLBACK_MS = 5_000; const CONFIG_SNAPSHOT_RECENT_STAT_FAILURE_FALLBACK_MS = 5_000;
const COARSE_FS_FULL_VERIFY_MS = 1_500; const COARSE_FS_FULL_VERIFY_MS = 1_500;
const LIST_TEAMS_CACHE_TTL_MS = 5_000;
const MAX_SESSION_HISTORY_IN_SUMMARY = 2000; const MAX_SESSION_HISTORY_IN_SUMMARY = 2000;
const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200; const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200;
const MAX_LAUNCH_STATE_BYTES = 32 * 1024; const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
@ -71,13 +72,32 @@ interface CachedTeamConfig {
fullVerifiedAt: number; fullVerifiedAt: number;
} }
type TeamConfigReadMode = 'verified' | 'snapshot';
interface ConfigReadTiming { interface ConfigReadTiming {
teamName: string; teamName: string;
mode: TeamConfigReadMode;
configPath: string;
size: number | null; size: number | null;
statMs: number | null; statMs: number | null;
readMs: number | null; readMs: number | null;
parseMs: number | null; parseMs: number | null;
totalMs: number; totalMs: number;
likelyCause: string;
fingerprintHighResolution: boolean | null;
cacheGeneration: number | null;
currentGeneration: number;
caller: string | null;
}
interface CachedTeamList {
value: TeamSummary[];
expiresAt: number;
}
interface InFlightTeamList {
promise: Promise<TeamSummary[]>;
generationAtStart: number;
} }
function normalizeProjectPathCandidate(value: unknown): string | undefined { function normalizeProjectPathCandidate(value: unknown): string | undefined {
@ -197,6 +217,48 @@ function cloneConfig(config: TeamConfig): TeamConfig {
return structuredClone(config); return structuredClone(config);
} }
function cloneTeamSummaries(teams: readonly TeamSummary[]): TeamSummary[] {
return structuredClone([...teams]);
}
function classifyConfigReadTiming(timing: {
statMs: number | null;
readMs: number | null;
parseMs: number | null;
}): string {
const statMs = timing.statMs ?? 0;
const readMs = timing.readMs ?? 0;
const parseMs = timing.parseMs ?? 0;
if (readMs >= 1_000 && readMs >= statMs * 2 && readMs >= parseMs * 2) {
return 'io_read_slow';
}
if (statMs >= 1_000 && statMs >= readMs * 2 && statMs >= parseMs * 2) {
return 'io_stat_slow';
}
if (parseMs >= 500 && parseMs >= readMs && parseMs >= statMs) {
return 'json_parse_slow';
}
if (statMs + readMs >= 1_000) {
return 'filesystem_pressure';
}
return 'mixed_or_unknown';
}
function captureConfigReadCaller(): string | null {
const stack = new Error().stack?.split('\n').slice(2) ?? [];
const frame = stack.find((line) => {
const normalized = line.trim();
return (
normalized.length > 0 &&
!normalized.includes('TeamConfigReader.') &&
!normalized.includes('TeamConfigReader.ts') &&
!normalized.includes('captureConfigReadCaller') &&
!normalized.includes('node:internal')
);
});
return frame?.trim().slice(0, 240) ?? null;
}
export class TeamConfigReader { export class TeamConfigReader {
private static readonly configCacheByPath = new Map<string, CachedTeamConfig>(); private static readonly configCacheByPath = new Map<string, CachedTeamConfig>();
private static readonly configReadInFlightByPath = new Map<string, Promise<TeamConfig | null>>(); private static readonly configReadInFlightByPath = new Map<string, Promise<TeamConfig | null>>();
@ -205,12 +267,18 @@ export class TeamConfigReader {
Promise<InternalTeamConfigFingerprint | null> Promise<InternalTeamConfigFingerprint | null>
>(); >();
private static readonly configGenerationByPath = new Map<string, number>(); private static readonly configGenerationByPath = new Map<string, number>();
private static readonly listTeamsCacheByBasePath = new Map<string, CachedTeamList>();
private static readonly listTeamsInFlightByBasePath = new Map<string, InFlightTeamList>();
private static listTeamsGeneration = 0;
static clearCacheForTests(): void { static clearCacheForTests(): void {
TeamConfigReader.configCacheByPath.clear(); TeamConfigReader.configCacheByPath.clear();
TeamConfigReader.configReadInFlightByPath.clear(); TeamConfigReader.configReadInFlightByPath.clear();
TeamConfigReader.configStatInFlightByPath.clear(); TeamConfigReader.configStatInFlightByPath.clear();
TeamConfigReader.configGenerationByPath.clear(); TeamConfigReader.configGenerationByPath.clear();
TeamConfigReader.listTeamsCacheByBasePath.clear();
TeamConfigReader.listTeamsInFlightByBasePath.clear();
TeamConfigReader.listTeamsGeneration = 0;
} }
static invalidateTeam(teamName: string): void { static invalidateTeam(teamName: string): void {
@ -223,6 +291,17 @@ export class TeamConfigReader {
TeamConfigReader.configReadInFlightByPath.delete(configPath); TeamConfigReader.configReadInFlightByPath.delete(configPath);
TeamConfigReader.configStatInFlightByPath.delete(configPath); TeamConfigReader.configStatInFlightByPath.delete(configPath);
TeamConfigReader.bumpConfigGeneration(configPath); TeamConfigReader.bumpConfigGeneration(configPath);
TeamConfigReader.invalidateListTeamsCache();
}
static invalidateListTeamsCache(): void {
TeamConfigReader.listTeamsCacheByBasePath.clear();
// Do not clear in-flight scans here. Config writes can arrive while a global
// team scan is already running; dropping the in-flight entry starts a second
// full scan over all teams and amplifies launch-time filesystem pressure.
// The generation check below prevents the stale in-flight result from being
// cached after invalidation.
TeamConfigReader.listTeamsGeneration += 1;
} }
private static invalidatePathForGeneration( private static invalidatePathForGeneration(
@ -245,6 +324,8 @@ export class TeamConfigReader {
): Promise<void> { ): Promise<void> {
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
const generation = TeamConfigReader.bumpConfigGeneration(configPath); const generation = TeamConfigReader.bumpConfigGeneration(configPath);
TeamConfigReader.configReadInFlightByPath.delete(configPath);
TeamConfigReader.configStatInFlightByPath.delete(configPath);
let internalFingerprint: InternalTeamConfigFingerprint | null = null; let internalFingerprint: InternalTeamConfigFingerprint | null = null;
if (fingerprint) { if (fingerprint) {
internalFingerprint = { internalFingerprint = {
@ -259,6 +340,7 @@ export class TeamConfigReader {
); );
} }
TeamConfigReader.storeConfigCache(configPath, config, internalFingerprint, true, generation); TeamConfigReader.storeConfigCache(configPath, config, internalFingerprint, true, generation);
TeamConfigReader.invalidateListTeamsCache();
} }
constructor( constructor(
@ -267,6 +349,44 @@ export class TeamConfigReader {
) {} ) {}
async listTeams(): Promise<TeamSummary[]> { async listTeams(): Promise<TeamSummary[]> {
const teamsBasePath = getTeamsBasePath();
const cached = TeamConfigReader.listTeamsCacheByBasePath.get(teamsBasePath);
if (cached && cached.expiresAt > Date.now()) {
return cloneTeamSummaries(cached.value);
}
const existingRequest = TeamConfigReader.listTeamsInFlightByBasePath.get(teamsBasePath);
if (
existingRequest &&
existingRequest.generationAtStart === TeamConfigReader.listTeamsGeneration
) {
return cloneTeamSummaries(await existingRequest.promise);
}
const request = this.listTeamsUncached(teamsBasePath);
const generationAtStart = TeamConfigReader.listTeamsGeneration;
TeamConfigReader.listTeamsInFlightByBasePath.set(teamsBasePath, {
promise: request,
generationAtStart,
});
try {
const teams = await request;
if (TeamConfigReader.listTeamsGeneration === generationAtStart) {
TeamConfigReader.listTeamsCacheByBasePath.set(teamsBasePath, {
value: cloneTeamSummaries(teams),
expiresAt: Date.now() + LIST_TEAMS_CACHE_TTL_MS,
});
}
return cloneTeamSummaries(teams);
} finally {
if (TeamConfigReader.listTeamsInFlightByBasePath.get(teamsBasePath)?.promise === request) {
TeamConfigReader.listTeamsInFlightByBasePath.delete(teamsBasePath);
}
}
}
private async listTeamsUncached(teamsBasePath: string): Promise<TeamSummary[]> {
const worker = getTeamFsWorkerClient(); const worker = getTeamFsWorkerClient();
if (worker.isAvailable()) { if (worker.isAvailable()) {
const startedAt = Date.now(); const startedAt = Date.now();
@ -304,7 +424,7 @@ export class TeamConfigReader {
} }
} }
const teamsDir = getTeamsBasePath(); const teamsDir = teamsBasePath;
let entries: fs.Dirent[]; let entries: fs.Dirent[];
try { try {
@ -413,6 +533,21 @@ export class TeamConfigReader {
const expectedTeammateNames = new Set<string>(); const expectedTeammateNames = new Set<string>();
const confirmedArtifactNames = new Set<string>(); const confirmedArtifactNames = new Set<string>();
let metaMembers: TeamMember[] = []; let metaMembers: TeamMember[] = [];
let leadName: string | undefined;
let leadColor: string | undefined;
const captureLeadMember = (m: TeamMember, overwrite = false): void => {
if (m.removedAt) return;
if (!isLeadMember(m)) return;
const name = m.name?.trim();
if (name && (overwrite || !leadName)) {
leadName = name;
}
const colorValue = m.color?.trim();
if (colorValue && (overwrite || !leadColor)) {
leadColor = colorValue;
}
};
const mergeMember = (m: TeamMember): void => { const mergeMember = (m: TeamMember): void => {
const name = m.name?.trim(); const name = m.name?.trim();
@ -437,6 +572,7 @@ export class TeamConfigReader {
for (const member of metaMembers) { for (const member of metaMembers) {
const name = member.name?.trim(); const name = member.name?.trim();
if (!name) continue; if (!name) continue;
captureLeadMember(member);
// Summary/memberCount should represent teammates (exclude the lead process). // Summary/memberCount should represent teammates (exclude the lead process).
if (name === 'user' || isLeadMember(member)) continue; if (name === 'user' || isLeadMember(member)) continue;
const key = name.toLowerCase(); const key = name.toLowerCase();
@ -462,6 +598,7 @@ export class TeamConfigReader {
for (const member of config.members) { for (const member of config.members) {
if (member && typeof member.name === 'string') { if (member && typeof member.name === 'string') {
const name = member.name.trim(); const name = member.name.trim();
captureLeadMember(member, true);
if (name && name !== 'user' && !isLeadMember(member)) { if (name && name !== 'user' && !isLeadMember(member)) {
confirmedArtifactNames.add(name); confirmedArtifactNames.add(name);
} }
@ -537,6 +674,8 @@ export class TeamConfigReader {
taskCount: 0, taskCount: 0,
lastActivity: null, lastActivity: null,
...(members.length > 0 ? { members } : {}), ...(members.length > 0 ? { members } : {}),
...(leadName ? { leadName } : {}),
...(leadColor ? { leadColor } : {}),
...(color ? { color } : {}), ...(color ? { color } : {}),
...(projectPath ? { projectPath } : {}), ...(projectPath ? { projectPath } : {}),
...(leadSessionId ? { leadSessionId } : {}), ...(leadSessionId ? { leadSessionId } : {}),
@ -578,11 +717,21 @@ export class TeamConfigReader {
: teamName; : teamName;
let memberCount = 0; let memberCount = 0;
let leadName: string | undefined;
let leadColor: string | undefined;
try { try {
const metaStore = new TeamMembersMetaStore(); const members = await this.membersMetaStore.getMembers(teamName);
const members = await metaStore.getMembers(teamName);
memberCount = members.filter((member) => { memberCount = members.filter((member) => {
const name = member.name?.trim() ?? ''; const name = member.name?.trim() ?? '';
if (!member.removedAt && isLeadMember(member)) {
if (name) {
leadName = name;
}
const color = member.color?.trim();
if (color) {
leadColor = color;
}
}
if (!name || name === 'user' || isLeadMember(member)) { if (!name || name === 'user' || isLeadMember(member)) {
return false; return false;
} }
@ -601,6 +750,8 @@ export class TeamConfigReader {
lastActivity: lastActivity:
typeof meta.createdAt === 'number' ? new Date(meta.createdAt).toISOString() : null, typeof meta.createdAt === 'number' ? new Date(meta.createdAt).toISOString() : null,
color: typeof meta.color === 'string' ? meta.color : undefined, color: typeof meta.color === 'string' ? meta.color : undefined,
...(leadName ? { leadName } : {}),
...(leadColor ? { leadColor } : {}),
projectPath: typeof meta.cwd === 'string' ? meta.cwd : undefined, projectPath: typeof meta.cwd === 'string' ? meta.cwd : undefined,
pendingCreate: true, pendingCreate: true,
}; };
@ -621,7 +772,14 @@ export class TeamConfigReader {
} }
const generation = TeamConfigReader.getConfigGeneration(configPath); const generation = TeamConfigReader.getConfigGeneration(configPath);
const readPromise = this.readConfigFromDisk(teamName, configPath, null, true, generation); const readPromise = this.readConfigFromDisk(
teamName,
configPath,
null,
true,
generation,
'verified'
);
TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise); TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise);
try { try {
@ -700,7 +858,8 @@ export class TeamConfigReader {
configPath, configPath,
fingerprint, fingerprint,
true, true,
generation generation,
'snapshot'
); );
TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise); TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise);
try { try {
@ -842,21 +1001,31 @@ export class TeamConfigReader {
configPath: string, configPath: string,
knownFingerprint: InternalTeamConfigFingerprint | null = null, knownFingerprint: InternalTeamConfigFingerprint | null = null,
updateCache = false, updateCache = false,
cacheGeneration?: number cacheGeneration?: number,
mode: TeamConfigReadMode = 'verified'
): Promise<TeamConfig | null> { ): Promise<TeamConfig | null> {
const startedAt = performance.now(); const startedAt = performance.now();
const caller = captureConfigReadCaller();
let size: number | null = null; let size: number | null = null;
let statMs: number | null = null; let statMs: number | null = null;
let readMs: number | null = null; let readMs: number | null = null;
let parseMs: number | null = null; let parseMs: number | null = null;
let fingerprintHighResolution: boolean | null = knownFingerprint?.highResolution ?? null;
const buildTiming = (): ConfigReadTiming => ({ const buildTiming = (): ConfigReadTiming => ({
teamName, teamName,
mode,
configPath,
size, size,
statMs, statMs,
readMs, readMs,
parseMs, parseMs,
totalMs: Math.round(performance.now() - startedAt), totalMs: Math.round(performance.now() - startedAt),
likelyCause: classifyConfigReadTiming({ statMs, readMs, parseMs }),
fingerprintHighResolution,
cacheGeneration: cacheGeneration ?? null,
currentGeneration: TeamConfigReader.getConfigGeneration(configPath),
caller,
}); });
try { try {
@ -865,6 +1034,7 @@ export class TeamConfigReader {
knownFingerprint ?? (await TeamConfigReader.getConfigFingerprint(configPath)); knownFingerprint ?? (await TeamConfigReader.getConfigFingerprint(configPath));
statMs = Math.round(performance.now() - statStartedAt); statMs = Math.round(performance.now() - statStartedAt);
size = fingerprint?.numericSize ?? null; size = fingerprint?.numericSize ?? null;
fingerprintHighResolution = fingerprint?.highResolution ?? null;
// Safety: refuse special files and huge/binary configs // Safety: refuse special files and huge/binary configs
if (!fingerprint?.isFile) { if (!fingerprint?.isFile) {

View file

@ -112,7 +112,7 @@ const PROCESS_HEALTH_INTERVAL_MS = 2_000;
const TASK_MAP_YIELD_EVERY = 250; const TASK_MAP_YIELD_EVERY = 250;
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification'; const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000; const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
const MEMBER_RUNTIME_ADVISORY_SNAPSHOT_BUDGET_MS = 750; const MEMBER_RUNTIME_ADVISORY_SNAPSHOT_BUDGET_MS = 250;
const MIXED_TEAM_LIVE_MUTATION_BLOCK_MESSAGE = const MIXED_TEAM_LIVE_MUTATION_BLOCK_MESSAGE =
'Live roster mutation on a running mixed team is not supported in V1. Stop the team, edit the roster, then relaunch.'; 'Live roster mutation on a running mixed team is not supported in V1. Stop the team, edit the roster, then relaunch.';
@ -422,6 +422,10 @@ export class TeamDataService {
return readConfigForUiSnapshot(this.configReader, teamName); return readConfigForUiSnapshot(this.configReader, teamName);
} }
private invalidateGlobalTaskProjectionCache(): void {
TeamTaskReader.invalidateAllTasksCache();
}
private getController(teamName: string): AgentTeamsController { private getController(teamName: string): AgentTeamsController {
return this.controllerFactory(teamName); return this.controllerFactory(teamName);
} }
@ -514,7 +518,7 @@ export class TeamDataService {
request.catch(() => { request.catch(() => {
/* background advisory refresh is best-effort */ /* background advisory refresh is best-effort */
}); });
logger.warn( logger.debug(
`getTeamData team=${teamName} member runtime advisories exceeded ${MEMBER_RUNTIME_ADVISORY_SNAPSHOT_BUDGET_MS}ms budget; continuing without advisories for this snapshot` `getTeamData team=${teamName} member runtime advisories exceeded ${MEMBER_RUNTIME_ADVISORY_SNAPSHOT_BUDGET_MS}ms budget; continuing without advisories for this snapshot`
); );
return new Map(); return new Map();
@ -865,7 +869,7 @@ export class TeamDataService {
} }
async getTaskChangePresence(teamName: string): Promise<Record<string, TaskChangePresenceState>> { async getTaskChangePresence(teamName: string): Promise<Record<string, TaskChangePresenceState>> {
const config = await this.configReader.getConfig(teamName); const config = await this.readSnapshotConfig(teamName);
if (!config) { if (!config) {
throw new Error(`Team not found: ${teamName}`); throw new Error(`Team not found: ${teamName}`);
} }
@ -1121,6 +1125,7 @@ export class TeamDataService {
const tasksDir = path.join(getTasksBasePath(), teamName); const tasksDir = path.join(getTasksBasePath(), teamName);
await fs.promises.rm(tasksDir, { recursive: true, force: true }); await fs.promises.rm(tasksDir, { recursive: true, force: true });
TeamTaskReader.invalidateAllTasksCache();
} }
async getTeamData(teamName: string): Promise<TeamViewSnapshot> { async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
@ -1915,6 +1920,7 @@ export class TeamDataService {
...(request.promptTaskRefs?.length ? { promptTaskRefs: request.promptTaskRefs } : {}), ...(request.promptTaskRefs?.length ? { promptTaskRefs: request.promptTaskRefs } : {}),
...(shouldStart ? { startImmediately: true } : {}), ...(shouldStart ? { startImmediately: true } : {}),
}) as TeamTask; }) as TeamTask;
this.invalidateGlobalTaskProjectionCache();
// Controller's maybeNotifyAssignedOwner skips the lead (owner === lead). // Controller's maybeNotifyAssignedOwner skips the lead (owner === lead).
// For user-created tasks with startImmediately, ensure the lead also gets notified. // For user-created tasks with startImmediately, ensure the lead also gets notified.
@ -1943,6 +1949,7 @@ export class TeamDataService {
} }
this.getController(teamName).tasks.startTask(taskId, 'user'); this.getController(teamName).tasks.startTask(taskId, 'user');
this.invalidateGlobalTaskProjectionCache();
if (task.owner) { if (task.owner) {
try { try {
@ -1995,6 +2002,7 @@ export class TeamDataService {
} }
this.getController(teamName).tasks.startTask(taskId, 'user'); this.getController(teamName).tasks.startTask(taskId, 'user');
this.invalidateGlobalTaskProjectionCache();
if (task.owner) { if (task.owner) {
await this.sendUserTaskStartNotification(teamName, task); await this.sendUserTaskStartNotification(teamName, task);
@ -2050,6 +2058,7 @@ export class TeamDataService {
actor?: string actor?: string
): Promise<void> { ): Promise<void> {
this.getController(teamName).tasks.setTaskStatus(taskId, status, actor); this.getController(teamName).tasks.setTaskStatus(taskId, status, actor);
this.invalidateGlobalTaskProjectionCache();
} }
/** /**
@ -2109,10 +2118,12 @@ export class TeamDataService {
async softDeleteTask(teamName: string, taskId: string): Promise<void> { async softDeleteTask(teamName: string, taskId: string): Promise<void> {
this.getController(teamName).tasks.softDeleteTask(taskId, 'user'); this.getController(teamName).tasks.softDeleteTask(taskId, 'user');
this.invalidateGlobalTaskProjectionCache();
} }
async restoreTask(teamName: string, taskId: string): Promise<void> { async restoreTask(teamName: string, taskId: string): Promise<void> {
this.getController(teamName).tasks.restoreTask(taskId, 'user'); this.getController(teamName).tasks.restoreTask(taskId, 'user');
this.invalidateGlobalTaskProjectionCache();
} }
async getDeletedTasks(teamName: string): Promise<TeamTask[]> { async getDeletedTasks(teamName: string): Promise<TeamTask[]> {
@ -2121,6 +2132,7 @@ export class TeamDataService {
async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise<void> { async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise<void> {
this.getController(teamName).tasks.setTaskOwner(taskId, owner); this.getController(teamName).tasks.setTaskOwner(taskId, owner);
this.invalidateGlobalTaskProjectionCache();
} }
async updateTaskFields( async updateTaskFields(
@ -2129,6 +2141,7 @@ export class TeamDataService {
fields: { subject?: string; description?: string } fields: { subject?: string; description?: string }
): Promise<void> { ): Promise<void> {
this.getController(teamName).tasks.updateTaskFields(taskId, fields); this.getController(teamName).tasks.updateTaskFields(taskId, fields);
this.invalidateGlobalTaskProjectionCache();
} }
async addTaskAttachment( async addTaskAttachment(
@ -2140,6 +2153,7 @@ export class TeamDataService {
taskId, taskId,
meta as unknown as Record<string, unknown> meta as unknown as Record<string, unknown>
); );
this.invalidateGlobalTaskProjectionCache();
} }
async removeTaskAttachment( async removeTaskAttachment(
@ -2148,6 +2162,7 @@ export class TeamDataService {
attachmentId: string attachmentId: string
): Promise<void> { ): Promise<void> {
this.getController(teamName).tasks.removeTaskAttachment(taskId, attachmentId); this.getController(teamName).tasks.removeTaskAttachment(taskId, attachmentId);
this.invalidateGlobalTaskProjectionCache();
} }
async setTaskNeedsClarification( async setTaskNeedsClarification(
@ -2156,6 +2171,7 @@ export class TeamDataService {
value: 'lead' | 'user' | null value: 'lead' | 'user' | null
): Promise<void> { ): Promise<void> {
this.getController(teamName).tasks.setNeedsClarification(taskId, value); this.getController(teamName).tasks.setNeedsClarification(taskId, value);
this.invalidateGlobalTaskProjectionCache();
} }
async addTaskRelationship( async addTaskRelationship(
@ -2169,6 +2185,7 @@ export class TeamDataService {
targetId, targetId,
type === 'blockedBy' ? 'blocked-by' : type type === 'blockedBy' ? 'blocked-by' : type
); );
this.invalidateGlobalTaskProjectionCache();
} }
async removeTaskRelationship( async removeTaskRelationship(
@ -2182,6 +2199,7 @@ export class TeamDataService {
targetId, targetId,
type === 'blockedBy' ? 'blocked-by' : type type === 'blockedBy' ? 'blocked-by' : type
); );
this.invalidateGlobalTaskProjectionCache();
} }
async addTaskComment( async addTaskComment(
@ -2198,6 +2216,7 @@ export class TeamDataService {
attachments, attachments,
taskRefs, taskRefs,
}) as { task?: TeamTask; comment?: TaskComment }; }) as { task?: TeamTask; comment?: TaskComment };
this.invalidateGlobalTaskProjectionCache();
const comment = const comment =
addResult.comment ?? addResult.comment ??
({ ({
@ -2832,7 +2851,7 @@ export class TeamDataService {
async getTeamDisplayName(teamName: string): Promise<string> { async getTeamDisplayName(teamName: string): Promise<string> {
try { try {
const config = await this.configReader.getConfig(teamName); const config = await this.readSnapshotConfig(teamName);
const displayName = config?.name?.trim(); const displayName = config?.name?.trim();
return displayName || teamName; return displayName || teamName;
} catch { } catch {
@ -2845,7 +2864,7 @@ export class TeamDataService {
projectPath?: string; projectPath?: string;
}> { }> {
try { try {
const config = await this.configReader.getConfig(teamName); const config = await this.readSnapshotConfig(teamName);
const displayName = config?.name?.trim() || teamName; const displayName = config?.name?.trim() || teamName;
const projectPath = const projectPath =
typeof config?.projectPath === 'string' && config.projectPath.trim().length > 0 typeof config?.projectPath === 'string' && config.projectPath.trim().length > 0
@ -2943,6 +2962,7 @@ export class TeamDataService {
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, { await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, {
providerBackendId: request.providerBackendId, providerBackendId: request.providerBackendId,
}); });
TeamConfigReader.invalidateListTeamsCache();
} }
async reconcileTeamArtifacts( async reconcileTeamArtifacts(

View file

@ -59,16 +59,50 @@ function resolveWorkerPath(): string | null {
} }
interface PendingEntry { interface PendingEntry {
resolve: (v: unknown) => void; resolve: (v: unknown, diag?: Extract<TeamDataWorkerResponse, { ok: true }>['diag']) => void;
reject: (e: Error) => void; reject: (e: Error) => void;
} }
function summarizeWorkerPayload(
payload: TeamDataWorkerRequest['payload']
): Record<string, unknown> {
if ('taskId' in payload) {
return {
teamName: payload.teamName,
taskId: payload.taskId,
owner: payload.options?.owner,
status: payload.options?.status,
intervals: Array.isArray(payload.options?.intervals)
? payload.options.intervals.length
: undefined,
since: payload.options?.since,
};
}
if ('options' in payload) {
return {
teamName: payload.teamName,
cursor:
typeof payload.options.cursor === 'string'
? payload.options.cursor.slice(0, 24)
: payload.options.cursor,
limit: payload.options.limit,
};
}
if ('teamName' in payload) {
return {
teamName: payload.teamName,
};
}
return {};
}
export class TeamDataWorkerClient { export class TeamDataWorkerClient {
private worker: Worker | null = null; private worker: Worker | null = null;
private readonly workerPath: string | null = resolveWorkerPath(); private readonly workerPath: string | null = resolveWorkerPath();
private warnedUnavailable = false; private warnedUnavailable = false;
private pending = new Map<string, PendingEntry>(); private pending = new Map<string, PendingEntry>();
private getTeamDataInFlight = new Map<string, Promise<TeamViewSnapshot>>(); private getTeamDataInFlight = new Map<string, Promise<TeamViewSnapshot>>();
private getMessagesPageInFlight = new Map<string, Promise<MessagesPage>>();
private failWorker(worker: Worker, error: Error): void { private failWorker(worker: Worker, error: Error): void {
if (this.worker !== worker) return; if (this.worker !== worker) return;
@ -104,7 +138,7 @@ export class TeamDataWorkerClient {
if (!entry) return; if (!entry) return;
this.pending.delete(msg.id); this.pending.delete(msg.id);
if (msg.ok) { if (msg.ok) {
entry.resolve(msg.result); entry.resolve(msg.result, msg.diag);
} else { } else {
entry.reject(new Error(msg.error)); entry.reject(new Error(msg.error));
} }
@ -132,22 +166,45 @@ export class TeamDataWorkerClient {
): Promise<unknown> { ): Promise<unknown> {
const worker = this.ensureWorker(); const worker = this.ensureWorker();
const id = makeId(); const id = makeId();
const startedAt = Date.now();
const pendingAtStart = this.pending.size;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
const timeoutError = new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms`); const timeoutError = new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms`);
logger.warn(
`worker call timeout op=${op} ms=${Date.now() - startedAt} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify(
summarizeWorkerPayload(payload)
)}`
);
this.failWorker(worker, timeoutError); this.failWorker(worker, timeoutError);
worker.terminate().catch(() => undefined); worker.terminate().catch(() => undefined);
reject(timeoutError); reject(timeoutError);
}, WORKER_CALL_TIMEOUT_MS); }, WORKER_CALL_TIMEOUT_MS);
this.pending.set(id, { this.pending.set(id, {
resolve: (value) => { resolve: (value, diag) => {
clearTimeout(timeout); clearTimeout(timeout);
const ms = Date.now() - startedAt;
if (ms >= 1500) {
logger.warn(
`worker call slow op=${op} ms=${ms} workerTotalMs=${String(diag?.totalMs ?? 'unknown')} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify(
summarizeWorkerPayload(payload)
)}`
);
}
resolve(value); resolve(value);
}, },
reject: (error) => { reject: (error) => {
clearTimeout(timeout); clearTimeout(timeout);
const ms = Date.now() - startedAt;
if (ms >= 1500) {
logger.warn(
`worker call failed slow op=${op} ms=${ms} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify(
summarizeWorkerPayload(payload)
)} error=${error.message}`
);
}
reject(error); reject(error);
}, },
}); });
@ -156,6 +213,23 @@ export class TeamDataWorkerClient {
}); });
} }
private postBestEffort(
op: TeamDataWorkerRequest['op'],
payload: TeamDataWorkerRequest['payload']
): void {
const worker = this.worker;
if (!worker) return;
try {
worker.postMessage({ id: makeId(), op, payload } as TeamDataWorkerRequest);
} catch (error) {
logger.debug(
`worker best-effort post failed op=${op} payload=${JSON.stringify(
summarizeWorkerPayload(payload)
)} error=${error instanceof Error ? error.message : String(error)}`
);
}
}
async getTeamData(teamName: string): Promise<TeamViewSnapshot> { async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName'); if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
const existing = this.getTeamDataInFlight.get(teamName); const existing = this.getTeamDataInFlight.get(teamName);
@ -175,8 +249,23 @@ export class TeamDataWorkerClient {
invalidateTeamConfig(teamName: string): void { invalidateTeamConfig(teamName: string): void {
if (!SAFE_NAME_RE.test(teamName)) return; if (!SAFE_NAME_RE.test(teamName)) return;
this.getTeamDataInFlight.delete(teamName); this.getTeamDataInFlight.delete(teamName);
if (!this.worker) return; this.clearMessagesPageInFlightForTeam(teamName);
void this.call('invalidateTeamConfig', { teamName }).catch(() => undefined); this.postBestEffort('invalidateTeamConfig', { teamName });
}
invalidateTeamMessageFeed(teamName: string): void {
if (!SAFE_NAME_RE.test(teamName)) return;
this.clearMessagesPageInFlightForTeam(teamName);
this.postBestEffort('invalidateTeamMessageFeed', { teamName });
}
private clearMessagesPageInFlightForTeam(teamName: string): void {
const prefix = `{"teamName":"${teamName}",`;
for (const key of this.getMessagesPageInFlight.keys()) {
if (key.startsWith(prefix)) {
this.getMessagesPageInFlight.delete(key);
}
}
} }
async getMessagesPage( async getMessagesPage(
@ -184,7 +273,26 @@ export class TeamDataWorkerClient {
options: { cursor?: string | null; limit: number } options: { cursor?: string | null; limit: number }
): Promise<MessagesPage> { ): Promise<MessagesPage> {
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName'); if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
return this.call('getMessagesPage', { teamName, options }) as Promise<MessagesPage>; const key = JSON.stringify({
teamName,
cursor: options.cursor ?? null,
limit: options.limit,
});
const existing = this.getMessagesPageInFlight.get(key);
if (existing) return existing;
const promise = (
this.call('getMessagesPage', {
teamName,
options,
}) as Promise<MessagesPage>
).finally(() => {
if (this.getMessagesPageInFlight.get(key) === promise) {
this.getMessagesPageInFlight.delete(key);
}
});
this.getMessagesPageInFlight.set(key, promise);
return promise;
} }
async getMemberActivityMeta(teamName: string): Promise<TeamMemberActivityMeta> { async getMemberActivityMeta(teamName: string): Promise<TeamMemberActivityMeta> {
@ -213,6 +321,7 @@ export class TeamDataWorkerClient {
this.worker?.terminate().catch(() => undefined); this.worker?.terminate().catch(() => undefined);
this.worker = null; this.worker = null;
this.getTeamDataInFlight.clear(); this.getTeamDataInFlight.clear();
this.getMessagesPageInFlight.clear();
for (const [, entry] of this.pending) { for (const [, entry] of this.pending) {
entry.reject(new Error('Client disposed')); entry.reject(new Error('Client disposed'));
} }

View file

@ -43,6 +43,27 @@ type WorkerResponse =
| { id: string; ok: true; result: unknown; diag?: WorkerDiag } | { id: string; ok: true; result: unknown; diag?: WorkerDiag }
| { id: string; ok: false; error: string }; | { id: string; ok: false; error: string };
function summarizeWorkerPayload(payload: WorkerRequest['payload']): Record<string, unknown> {
if ('teamsDir' in payload) {
return {
teamsDir: payload.teamsDir,
concurrency: payload.concurrency,
maxConfigReadMs: payload.maxConfigReadMs,
maxConfigBytes: payload.maxConfigBytes,
};
}
return {
tasksBase: payload.tasksBase,
concurrency: payload.concurrency,
maxTaskReadMs: payload.maxTaskReadMs,
maxTaskBytes: payload.maxTaskBytes,
};
}
function getDiagTotalMs(diag: WorkerDiag | undefined): unknown {
return diag && typeof diag === 'object' ? diag.totalMs : undefined;
}
function makeId(): string { function makeId(): string {
return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`; return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`;
} }
@ -152,6 +173,8 @@ export class TeamFsWorkerClient {
): Promise<{ result: unknown; diag?: WorkerDiag }> { ): Promise<{ result: unknown; diag?: WorkerDiag }> {
const worker = this.ensureWorker(); const worker = this.ensureWorker();
const id = makeId(); const id = makeId();
const startedAt = Date.now();
const pendingAtStart = this.pending.size;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
this.pending.delete(id); this.pending.delete(id);
@ -163,16 +186,37 @@ export class TeamFsWorkerClient {
} finally { } finally {
this.worker = null; this.worker = null;
} }
logger.warn(
`worker call timeout op=${op} ms=${Date.now() - startedAt} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify(
summarizeWorkerPayload(payload)
)}`
);
reject(new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms (${op})`)); reject(new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms (${op})`));
}, WORKER_CALL_TIMEOUT_MS); }, WORKER_CALL_TIMEOUT_MS);
this.pending.set(id, { this.pending.set(id, {
resolve: (value) => { resolve: (value) => {
clearTimeout(timeout); clearTimeout(timeout);
const ms = Date.now() - startedAt;
if (ms >= 1500) {
logger.warn(
`worker call slow op=${op} ms=${ms} workerTotalMs=${String(getDiagTotalMs(value.diag))} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify(
summarizeWorkerPayload(payload)
)}`
);
}
resolve(value); resolve(value);
}, },
reject: (error) => { reject: (error) => {
clearTimeout(timeout); clearTimeout(timeout);
const ms = Date.now() - startedAt;
if (ms >= 1500) {
logger.warn(
`worker call failed slow op=${op} ms=${ms} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify(
summarizeWorkerPayload(payload)
)} error=${error.message}`
);
}
reject(error); reject(error);
}, },
}); });

View file

@ -148,7 +148,9 @@ export class TeamMemberLogsFinder {
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(), private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(), private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver( private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver(
configReader {
getConfig: (teamName) => configReader.getConfigSnapshot(teamName),
}
) )
) {} ) {}

View file

@ -27,7 +27,7 @@ interface RuntimeAdvisoryLogsFinder {
const LOOKBACK_MS = 10 * 60 * 1000; const LOOKBACK_MS = 10 * 60 * 1000;
const CACHE_TTL_MS = 30_000; const CACHE_TTL_MS = 30_000;
const TAIL_BYTES = 64 * 1024; const TAIL_BYTES = 64 * 1024;
const BATCH_WARN_MS = 200; const BATCH_WARN_MS = 1_000;
const ADVISORY_FETCH_CONCURRENCY = 2; const ADVISORY_FETCH_CONCURRENCY = 2;
const QUOTA_EXHAUSTED_TOKENS = [ const QUOTA_EXHAUSTED_TOKENS = [
'exhausted your capacity', 'exhausted your capacity',

View file

@ -26,6 +26,11 @@ interface TeamMessageFeedCacheEntry {
cachedAt: number; cachedAt: number;
} }
interface InFlightTeamMessageFeed {
promise: Promise<TeamNormalizedMessageFeed>;
generationAtStart: number;
}
export interface TeamNormalizedMessageFeed { export interface TeamNormalizedMessageFeed {
teamName: string; teamName: string;
feedRevision: string; feedRevision: string;
@ -411,11 +416,14 @@ function toFeedRevision(messages: readonly InboxMessage[]): string {
export class TeamMessageFeedService { export class TeamMessageFeedService {
private readonly cacheByTeam = new Map<string, TeamMessageFeedCacheEntry>(); private readonly cacheByTeam = new Map<string, TeamMessageFeedCacheEntry>();
private readonly dirtyTeams = new Set<string>(); private readonly dirtyTeams = new Set<string>();
private readonly inFlightByTeam = new Map<string, InFlightTeamMessageFeed>();
private readonly generationByTeam = new Map<string, number>();
constructor(private readonly deps: TeamMessageFeedDeps) {} constructor(private readonly deps: TeamMessageFeedDeps) {}
invalidate(teamName: string): void { invalidate(teamName: string): void {
this.dirtyTeams.add(teamName); this.dirtyTeams.add(teamName);
this.generationByTeam.set(teamName, this.getGeneration(teamName) + 1);
} }
async getFeed(teamName: string): Promise<TeamNormalizedMessageFeed> { async getFeed(teamName: string): Promise<TeamNormalizedMessageFeed> {
@ -431,20 +439,65 @@ export class TeamMessageFeedService {
}; };
} }
const existingRequest = this.inFlightByTeam.get(teamName);
const generationAtStart = this.getGeneration(teamName);
if (existingRequest && existingRequest.generationAtStart === generationAtStart) {
return existingRequest.promise;
}
const request = this.buildFeed(
teamName,
cached,
now,
cacheDirty,
cacheExpired,
generationAtStart
).finally(() => {
if (this.inFlightByTeam.get(teamName)?.promise === request) {
this.inFlightByTeam.delete(teamName);
}
});
this.inFlightByTeam.set(teamName, {
promise: request,
generationAtStart,
});
return request;
}
private getGeneration(teamName: string): number {
return this.generationByTeam.get(teamName) ?? 0;
}
private async buildFeed(
teamName: string,
cached: TeamMessageFeedCacheEntry | undefined,
now: number,
cacheDirty: boolean,
cacheExpired: boolean,
generationAtStart: number
): Promise<TeamNormalizedMessageFeed> {
const startedAt = Date.now();
const configStartedAt = Date.now();
const config = await this.deps.getConfig(teamName); const config = await this.deps.getConfig(teamName);
const configMs = Date.now() - configStartedAt;
if (!config) { if (!config) {
const emptyEntry = { feedRevision: toFeedRevision([]), messages: [], cachedAt: now }; const emptyEntry = { feedRevision: toFeedRevision([]), messages: [], cachedAt: now };
this.cacheByTeam.set(teamName, emptyEntry); if (this.getGeneration(teamName) === generationAtStart) {
this.dirtyTeams.delete(teamName); this.cacheByTeam.set(teamName, emptyEntry);
this.dirtyTeams.delete(teamName);
}
return { teamName, ...emptyEntry }; return { teamName, ...emptyEntry };
} }
const sourceStartedAt = Date.now();
const [inboxMessages, leadTexts, sentMessages] = await Promise.all([ const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
this.deps.getInboxMessages(teamName).catch(() => [] as InboxMessage[]), this.deps.getInboxMessages(teamName).catch(() => [] as InboxMessage[]),
this.deps.getLeadSessionMessages(teamName, config).catch(() => [] as InboxMessage[]), this.deps.getLeadSessionMessages(teamName, config).catch(() => [] as InboxMessage[]),
this.deps.getSentMessages(teamName).catch(() => [] as InboxMessage[]), this.deps.getSentMessages(teamName).catch(() => [] as InboxMessage[]),
]); ]);
const sourceMs = Date.now() - sourceStartedAt;
const normalizeStartedAt = Date.now();
const syntheticMessages = buildSyntheticOpenCodeBootstrapMessages(config); const syntheticMessages = buildSyntheticOpenCodeBootstrapMessages(config);
let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages]; let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages];
messages = dedupeLeadProcessCopies(messages, leadTexts); messages = dedupeLeadProcessCopies(messages, leadTexts);
@ -461,6 +514,13 @@ export class TeamMessageFeedService {
}); });
const feedRevision = toFeedRevision(messages); const feedRevision = toFeedRevision(messages);
const normalizeMs = Date.now() - normalizeStartedAt;
const totalMs = Date.now() - startedAt;
if (totalMs >= 750) {
logger.warn(
`[${teamName}] message feed build slow totalMs=${totalMs} configMs=${configMs} sourceMs=${sourceMs} normalizeMs=${normalizeMs} inbox=${inboxMessages.length} lead=${leadTexts.length} sent=${sentMessages.length} synthetic=${syntheticMessages.length} cacheDirty=${cacheDirty} cacheExpired=${cacheExpired}`
);
}
if (cached && !cacheDirty && cacheExpired && cached.feedRevision !== feedRevision) { if (cached && !cacheDirty && cacheExpired && cached.feedRevision !== feedRevision) {
logger.warn( logger.warn(
`[${teamName}] Message feed cache expired without dirty invalidation and recovered newer durable messages` `[${teamName}] Message feed cache expired without dirty invalidation and recovered newer durable messages`
@ -478,8 +538,10 @@ export class TeamMessageFeedService {
cachedAt: now, cachedAt: now,
}; };
this.cacheByTeam.set(teamName, nextEntry); if (this.getGeneration(teamName) === generationAtStart) {
this.dirtyTeams.delete(teamName); this.cacheByTeam.set(teamName, nextEntry);
this.dirtyTeams.delete(teamName);
}
return { return {
teamName, teamName,
feedRevision: nextEntry.feedRevision, feedRevision: nextEntry.feedRevision,

View file

@ -4599,7 +4599,18 @@ export class TeamProvisioningService {
this.inboxReader, this.inboxReader,
this.membersMetaStore this.membersMetaStore
); );
this.transcriptProjectResolver = new TeamTranscriptProjectResolver(this.configReader); this.transcriptProjectResolver = new TeamTranscriptProjectResolver({
getConfig: (teamName) => this.configReader.getConfigSnapshot(teamName),
});
}
private async readConfigSnapshot(teamName: string): Promise<TeamConfig | null> {
const configReader = this.configReader as TeamConfigReader & {
getConfigSnapshot?: (name: string) => Promise<TeamConfig | null>;
};
return typeof configReader.getConfigSnapshot === 'function'
? configReader.getConfigSnapshot(teamName)
: configReader.getConfig(teamName);
} }
setRuntimeAdapterRegistry(registry: TeamRuntimeAdapterRegistry | null): void { setRuntimeAdapterRegistry(registry: TeamRuntimeAdapterRegistry | null): void {
@ -5473,16 +5484,15 @@ export class TeamProvisioningService {
}; };
} }
async isOpenCodeRuntimeRecipient(teamName: string, memberName: string): Promise<boolean> { private resolveRuntimeRecipientProviderIdFromSources(
memberName: string,
config: TeamConfig | null | undefined,
metaMembers: readonly TeamMember[]
): TeamProviderId | undefined {
const normalizedMemberName = memberName.trim().toLowerCase(); const normalizedMemberName = memberName.trim().toLowerCase();
if (!normalizedMemberName) { if (!normalizedMemberName) {
return false; return undefined;
} }
const [config, metaMembers] = await Promise.all([
this.configReader.getConfig(teamName).catch(() => null),
this.membersMetaStore.getMembers(teamName).catch(() => []),
]);
const configMember = config?.members?.find( const configMember = config?.members?.find(
(member) => member.name?.trim().toLowerCase() === normalizedMemberName (member) => member.name?.trim().toLowerCase() === normalizedMemberName
); );
@ -5491,13 +5501,37 @@ export class TeamProvisioningService {
); );
const configProvider = (configMember as { provider?: unknown } | undefined)?.provider; const configProvider = (configMember as { provider?: unknown } | undefined)?.provider;
const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider; const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider;
const providerId = return (
normalizeTeamProviderLike(metaMember?.providerId) ?? normalizeTeamProviderLike(metaMember?.providerId) ??
normalizeTeamProviderLike(metaProvider) ?? normalizeTeamProviderLike(metaProvider) ??
normalizeTeamProviderLike(configMember?.providerId) ?? normalizeTeamProviderLike(configMember?.providerId) ??
normalizeTeamProviderLike(configProvider) ?? normalizeTeamProviderLike(configProvider) ??
inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model); inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model)
return providerId === 'opencode'; );
}
private isOpenCodeRuntimeRecipientFromSources(
memberName: string,
config: TeamConfig | null | undefined,
metaMembers: readonly TeamMember[]
): boolean {
return (
this.resolveRuntimeRecipientProviderIdFromSources(memberName, config, metaMembers) ===
'opencode'
);
}
async isOpenCodeRuntimeRecipient(teamName: string, memberName: string): Promise<boolean> {
const normalizedMemberName = memberName.trim().toLowerCase();
if (!normalizedMemberName) {
return false;
}
const [config, metaMembers] = await Promise.all([
this.readConfigSnapshot(teamName).catch(() => null),
this.membersMetaStore.getMembers(teamName).catch(() => []),
]);
return this.isOpenCodeRuntimeRecipientFromSources(normalizedMemberName, config, metaMembers);
} }
private isOpenCodeDeliveryResponseReadCommitAllowed(input: { private isOpenCodeDeliveryResponseReadCommitAllowed(input: {
@ -10334,7 +10368,8 @@ export class TeamProvisioningService {
let configuredMembers: TeamConfig['members'] = []; let configuredMembers: TeamConfig['members'] = [];
try { try {
configuredMembers = (await this.configReader.getConfig(teamName))?.members ?? []; const config = await this.readConfigSnapshot(teamName);
configuredMembers = config?.members ?? [];
} catch { } catch {
configuredMembers = []; configuredMembers = [];
} }
@ -10671,6 +10706,7 @@ export class TeamProvisioningService {
} }
parsed.members = members; parsed.members = members;
await atomicWriteAsync(configPath, `${JSON.stringify(parsed, null, 2)}\n`); await atomicWriteAsync(configPath, `${JSON.stringify(parsed, null, 2)}\n`);
TeamConfigReader.invalidateTeam(input.teamName);
} }
private enqueueDirectRestartPrompt(input: { private enqueueDirectRestartPrompt(input: {
@ -14434,6 +14470,7 @@ export class TeamProvisioningService {
], ],
}; };
await atomicWriteAsync(configPath, `${JSON.stringify(config, null, 2)}\n`); await atomicWriteAsync(configPath, `${JSON.stringify(config, null, 2)}\n`);
TeamConfigReader.invalidateTeam(request.teamName);
} }
private async persistOpenCodeRuntimeAdapterLaunchResult( private async persistOpenCodeRuntimeAdapterLaunchResult(
@ -15760,14 +15797,18 @@ export class TeamProvisioningService {
return { kind: 'ignored', relayed: 0 }; return { kind: 'ignored', relayed: 0 };
} }
const leadName = await this.configReader const [config, metaMembers] = await Promise.all([
.getConfig(teamName) this.readConfigSnapshot(teamName).catch(() => null),
.then( this.membersMetaStore.getMembers(teamName).catch(() => []),
(config) => config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null ]);
) const leadName = config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null;
.catch(() => null); const isOpenCodeRecipient = this.isOpenCodeRuntimeRecipientFromSources(
inboxName,
config,
metaMembers
);
if (inboxName.trim().toLowerCase() === leadName?.toLowerCase()) { if (inboxName.trim().toLowerCase() === leadName?.toLowerCase()) {
if (await this.isOpenCodeRuntimeRecipient(teamName, inboxName)) { if (isOpenCodeRecipient) {
const diagnostic = const diagnostic =
'opencode_lead_runtime_session_missing: OpenCode lead inbox relay is unsupported in v1; leaving inbox unread for durable retry/diagnostics.'; 'opencode_lead_runtime_session_missing: OpenCode lead inbox relay is unsupported in v1; leaving inbox unread for durable retry/diagnostics.';
logger.warn(`[${teamName}] ${diagnostic} inbox=${inboxName}`); logger.warn(`[${teamName}] ${diagnostic} inbox=${inboxName}`);
@ -15783,7 +15824,7 @@ export class TeamProvisioningService {
}; };
} }
if (await this.isOpenCodeRuntimeRecipient(teamName, inboxName)) { if (isOpenCodeRecipient) {
const relayOptions: OpenCodeMemberInboxRelayOptions = { const relayOptions: OpenCodeMemberInboxRelayOptions = {
source: options.source ?? 'watcher', source: options.source ?? 'watcher',
...(options.onlyMessageId ? { onlyMessageId: options.onlyMessageId } : {}), ...(options.onlyMessageId ? { onlyMessageId: options.onlyMessageId } : {}),
@ -17444,7 +17485,7 @@ export class TeamProvisioningService {
let configuredMembers: TeamConfig['members'] = []; let configuredMembers: TeamConfig['members'] = [];
try { try {
configuredMembers = (await this.configReader.getConfig(teamName))?.members ?? []; configuredMembers = (await this.readConfigSnapshot(teamName))?.members ?? [];
} catch { } catch {
configuredMembers = []; configuredMembers = [];
} }
@ -20169,9 +20210,9 @@ export class TeamProvisioningService {
memberName: string, memberName: string,
sinceMs: number | null sinceMs: number | null
): Promise<BootstrapTranscriptOutcome[]> { ): Promise<BootstrapTranscriptOutcome[]> {
let config: Awaited<ReturnType<TeamConfigReader['getConfig']>>; let config: TeamConfig | null;
try { try {
config = await this.configReader.getConfig(teamName); config = await this.readConfigSnapshot(teamName);
} catch { } catch {
return []; return [];
} }
@ -20221,7 +20262,7 @@ export class TeamProvisioningService {
private async collectBootstrapTranscriptProjectDirs( private async collectBootstrapTranscriptProjectDirs(
teamName: string, teamName: string,
memberName: string, memberName: string,
config: Awaited<ReturnType<TeamConfigReader['getConfig']>> config: TeamConfig | null
): Promise<string[]> { ): Promise<string[]> {
const pathCandidates: string[] = []; const pathCandidates: string[] = [];
const pathSeen = new Set<string>(); const pathSeen = new Set<string>();
@ -24435,6 +24476,7 @@ export class TeamProvisioningService {
config.projectPathHistory = pathHistory.slice(-500); config.projectPathHistory = pathHistory.slice(-500);
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
TeamConfigReader.invalidateTeam(teamName);
logger.info(`[${teamName}] Updated config.projectPath immediately: ${cwd}`); logger.info(`[${teamName}] Updated config.projectPath immediately: ${cwd}`);
} catch (error) { } catch (error) {
// Non-fatal: updateConfigPostLaunch will update it later if provisioning succeeds. // Non-fatal: updateConfigPostLaunch will update it later if provisioning succeeds.
@ -24671,6 +24713,7 @@ export class TeamProvisioningService {
this.applyEffectiveLaunchStateToConfig(teamName, config, launchState); this.applyEffectiveLaunchStateToConfig(teamName, config, launchState);
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
TeamConfigReader.invalidateTeam(teamName);
} catch (error) { } catch (error) {
logger.warn( logger.warn(
`[${teamName}] Failed to update config post-launch: ${ `[${teamName}] Failed to update config post-launch: ${
@ -24721,6 +24764,7 @@ export class TeamProvisioningService {
if (removedFromConfig.length > 0) { if (removedFromConfig.length > 0) {
parsed.members = nextMembers; parsed.members = nextMembers;
await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2)); await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2));
TeamConfigReader.invalidateTeam(teamName);
logger.warn( logger.warn(
`[${teamName}] Removed CLI auto-suffixed members from config.json: ${removedFromConfig.join(', ')}` `[${teamName}] Removed CLI auto-suffixed members from config.json: ${removedFromConfig.join(', ')}`
); );
@ -24977,6 +25021,7 @@ export class TeamProvisioningService {
config.members = leadMembers; config.members = leadMembers;
try { try {
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
TeamConfigReader.invalidateTeam(teamName);
logger.info( logger.info(
`[${teamName}] Normalized config.json for launch: kept ${leadMembers.length} lead member(s)` `[${teamName}] Normalized config.json for launch: kept ${leadMembers.length} lead member(s)`
); );
@ -25008,6 +25053,7 @@ export class TeamProvisioningService {
return; return;
} }
await atomicWriteAsync(configPath, backupRaw); await atomicWriteAsync(configPath, backupRaw);
TeamConfigReader.invalidateTeam(teamName);
logger.info(`[${teamName}] Restored config.json from prelaunch backup after launch failure`); logger.info(`[${teamName}] Restored config.json from prelaunch backup after launch failure`);
} catch { } catch {
logger.debug(`[${teamName}] No prelaunch backup to restore (or read failed)`); logger.debug(`[${teamName}] No prelaunch backup to restore (or read failed)`);

View file

@ -21,13 +21,18 @@ import type {
const logger = createLogger('Service:TeamTaskReader'); const logger = createLogger('Service:TeamTaskReader');
const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024; const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024;
const ALL_TASKS_CACHE_TTL_MS = 500; const ALL_TASKS_CACHE_TTL_MS = 5_000;
interface CachedAllTasks { interface CachedAllTasks {
value: (TeamTask & { teamName: string })[]; value: (TeamTask & { teamName: string })[];
expiresAt: number; expiresAt: number;
} }
interface InFlightAllTasks {
promise: Promise<(TeamTask & { teamName: string })[]>;
generationAtStart: number;
}
function cloneTasks<T>(tasks: T[]): T[] { function cloneTasks<T>(tasks: T[]): T[] {
return structuredClone(tasks); return structuredClone(tasks);
} }
@ -74,7 +79,13 @@ function normalizeTaskRefs(value: unknown): TaskRef[] | undefined {
export class TeamTaskReader { export class TeamTaskReader {
private static allTasksCache: CachedAllTasks | null = null; private static allTasksCache: CachedAllTasks | null = null;
private static allTasksInFlight: Promise<(TeamTask & { teamName: string })[]> | null = null; private static allTasksInFlight: InFlightAllTasks | null = null;
private static allTasksGeneration = 0;
static invalidateAllTasksCache(): void {
TeamTaskReader.allTasksCache = null;
TeamTaskReader.allTasksGeneration += 1;
}
/** /**
* Returns the next available numeric task ID by scanning ALL task files * Returns the next available numeric task ID by scanning ALL task files
@ -446,26 +457,55 @@ export class TeamTaskReader {
} }
async getAllTasks(): Promise<(TeamTask & { teamName: string })[]> { async getAllTasks(): Promise<(TeamTask & { teamName: string })[]> {
const startedAt = Date.now();
const cached = TeamTaskReader.allTasksCache; const cached = TeamTaskReader.allTasksCache;
if (cached && cached.expiresAt > Date.now()) { if (cached && cached.expiresAt > Date.now()) {
return cloneTasks(cached.value); const cloned = cloneTasks(cached.value);
const ms = Date.now() - startedAt;
if (ms >= 1500) {
logger.warn(`[getAllTasks] cache clone slow ms=${ms} tasks=${cloned.length}`);
}
return cloned;
} }
if (TeamTaskReader.allTasksInFlight) { if (
return cloneTasks(await TeamTaskReader.allTasksInFlight); TeamTaskReader.allTasksInFlight &&
TeamTaskReader.allTasksInFlight.generationAtStart === TeamTaskReader.allTasksGeneration
) {
const waitedAt = Date.now();
const tasks = await TeamTaskReader.allTasksInFlight.promise;
const cloned = cloneTasks(tasks);
const ms = Date.now() - startedAt;
if (ms >= 1500) {
logger.warn(
`[getAllTasks] in-flight wait slow ms=${ms} waitMs=${Date.now() - waitedAt} tasks=${cloned.length}`
);
}
return cloned;
} }
const request = this.readAllTasksUncached(); const request = this.readAllTasksUncached();
TeamTaskReader.allTasksInFlight = request; const generationAtStart = TeamTaskReader.allTasksGeneration;
TeamTaskReader.allTasksInFlight = {
promise: request,
generationAtStart,
};
try { try {
const tasks = await request; const tasks = await request;
TeamTaskReader.allTasksCache = { if (TeamTaskReader.allTasksGeneration === generationAtStart) {
value: cloneTasks(tasks), TeamTaskReader.allTasksCache = {
expiresAt: Date.now() + ALL_TASKS_CACHE_TTL_MS, value: cloneTasks(tasks),
}; expiresAt: Date.now() + ALL_TASKS_CACHE_TTL_MS,
return cloneTasks(tasks); };
}
const cloned = cloneTasks(tasks);
const ms = Date.now() - startedAt;
if (ms >= 1500) {
logger.warn(`[getAllTasks] total slow ms=${ms} tasks=${cloned.length}`);
}
return cloned;
} finally { } finally {
if (TeamTaskReader.allTasksInFlight === request) { if (TeamTaskReader.allTasksInFlight?.promise === request) {
TeamTaskReader.allTasksInFlight = null; TeamTaskReader.allTasksInFlight = null;
} }
} }

View file

@ -42,6 +42,17 @@ export interface InvalidateTeamConfigPayload {
teamName: string; teamName: string;
} }
export interface InvalidateTeamMessageFeedPayload {
teamName: string;
}
export interface TeamDataWorkerDiag {
op: TeamDataWorkerRequest['op'];
teamName?: string;
taskId?: string;
totalMs: number;
}
// ── Request / Response ── // ── Request / Response ──
export type TeamDataWorkerRequest = export type TeamDataWorkerRequest =
@ -49,12 +60,14 @@ export type TeamDataWorkerRequest =
| { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload } | { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload }
| { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload } | { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload }
| { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload } | { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload }
| { id: string; op: 'invalidateTeamConfig'; payload: InvalidateTeamConfigPayload }; | { id: string; op: 'invalidateTeamConfig'; payload: InvalidateTeamConfigPayload }
| { id: string; op: 'invalidateTeamMessageFeed'; payload: InvalidateTeamMessageFeedPayload };
export type TeamDataWorkerResponse = export type TeamDataWorkerResponse =
| { | {
id: string; id: string;
ok: true; ok: true;
result: TeamViewSnapshot | MessagesPage | TeamMemberActivityMeta | MemberLogSummary[] | null; result: TeamViewSnapshot | MessagesPage | TeamMemberActivityMeta | MemberLogSummary[] | null;
diag?: TeamDataWorkerDiag;
} }
| { id: string; ok: false; error: string }; | { id: string; ok: false; error: string };

View file

@ -36,11 +36,18 @@ function respond(msg: TeamDataWorkerResponse): void {
} }
parentPort?.on('message', async (msg: TeamDataWorkerRequest) => { parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
const startedAt = Date.now();
const buildDiag = (): NonNullable<Extract<TeamDataWorkerResponse, { ok: true }>['diag']> => ({
op: msg.op,
...('teamName' in msg.payload ? { teamName: msg.payload.teamName } : {}),
...('taskId' in msg.payload ? { taskId: msg.payload.taskId } : {}),
totalMs: Date.now() - startedAt,
});
try { try {
switch (msg.op) { switch (msg.op) {
case 'getTeamData': { case 'getTeamData': {
const result = await teamDataService.getTeamData(msg.payload.teamName); const result = await teamDataService.getTeamData(msg.payload.teamName);
respond({ id: msg.id, ok: true, result }); respond({ id: msg.id, ok: true, result, diag: buildDiag() });
break; break;
} }
case 'getMessagesPage': { case 'getMessagesPage': {
@ -48,17 +55,23 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
msg.payload.teamName, msg.payload.teamName,
msg.payload.options msg.payload.options
); );
respond({ id: msg.id, ok: true, result }); respond({ id: msg.id, ok: true, result, diag: buildDiag() });
break; break;
} }
case 'getMemberActivityMeta': { case 'getMemberActivityMeta': {
const result = await teamDataService.getMemberActivityMeta(msg.payload.teamName); const result = await teamDataService.getMemberActivityMeta(msg.payload.teamName);
respond({ id: msg.id, ok: true, result }); respond({ id: msg.id, ok: true, result, diag: buildDiag() });
break; break;
} }
case 'invalidateTeamConfig': { case 'invalidateTeamConfig': {
TeamConfigReader.invalidateTeam(msg.payload.teamName); TeamConfigReader.invalidateTeam(msg.payload.teamName);
respond({ id: msg.id, ok: true, result: null }); teamDataService.invalidateMessageFeed(msg.payload.teamName);
respond({ id: msg.id, ok: true, result: null, diag: buildDiag() });
break;
}
case 'invalidateTeamMessageFeed': {
teamDataService.invalidateMessageFeed(msg.payload.teamName);
respond({ id: msg.id, ok: true, result: null, diag: buildDiag() });
break; break;
} }
case 'findLogsForTask': { case 'findLogsForTask': {
@ -95,7 +108,7 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
logsInFlight.set(cacheKey, promise); logsInFlight.set(cacheKey, promise);
} }
const result = await promise; const result = await promise;
respond({ id: msg.id, ok: true, result }); respond({ id: msg.id, ok: true, result, diag: buildDiag() });
break; break;
} }
default: { default: {

View file

@ -384,13 +384,14 @@ async function readLaunchState(
*/ */
async function readDraftTeamMeta( async function readDraftTeamMeta(
teamsDir: string, teamsDir: string,
teamName: string teamName: string,
options: { maxConfigReadMs: number; maxMembersMetaBytes: number }
): Promise<Record<string, unknown> | null> { ): Promise<Record<string, unknown> | null> {
const metaPath = path.join(teamsDir, teamName, 'team.meta.json'); const metaPath = path.join(teamsDir, teamName, 'team.meta.json');
try { try {
const stat = await fs.promises.stat(metaPath); const stat = await fs.promises.stat(metaPath);
if (!stat.isFile() || stat.size > 256 * 1024) return null; if (!stat.isFile() || stat.size > 256 * 1024) return null;
const raw = await fs.promises.readFile(metaPath, 'utf8'); const raw = await readFileUtf8WithTimeout(metaPath, options.maxConfigReadMs);
const meta = JSON.parse(raw) as Record<string, unknown>; const meta = JSON.parse(raw) as Record<string, unknown>;
if (meta?.version !== 1 || typeof meta?.cwd !== 'string') return null; if (meta?.version !== 1 || typeof meta?.cwd !== 'string') return null;
@ -401,14 +402,29 @@ async function readDraftTeamMeta(
// Read members.meta.json for member count // Read members.meta.json for member count
let memberCount = 0; let memberCount = 0;
let leadName: string | undefined;
let leadColor: string | undefined;
try { try {
const membersPath = path.join(teamsDir, teamName, 'members.meta.json'); const membersPath = path.join(teamsDir, teamName, 'members.meta.json');
const membersRaw = await fs.promises.readFile(membersPath, 'utf8'); const membersStat = await fs.promises.stat(membersPath);
if (!membersStat.isFile() || membersStat.size > options.maxMembersMetaBytes) {
throw new Error('members_meta_too_large');
}
const membersRaw = await readFileUtf8WithTimeout(membersPath, options.maxConfigReadMs);
const membersData = JSON.parse(membersRaw) as { members?: unknown[] }; const membersData = JSON.parse(membersRaw) as { members?: unknown[] };
if (Array.isArray(membersData?.members)) { if (Array.isArray(membersData?.members)) {
memberCount = membersData.members.filter((member) => { memberCount = membersData.members.filter((member) => {
if (!isRawMember(member)) return false; if (!isRawMember(member)) return false;
const name = typeof member.name === 'string' ? member.name.trim() : ''; const name = typeof member.name === 'string' ? member.name.trim() : '';
if (!member.removedAt && isLeadMember(member)) {
if (name) {
leadName = name;
}
const color = typeof member.color === 'string' ? member.color.trim() : '';
if (color) {
leadColor = color;
}
}
if (!name || name === 'user' || isLeadMember(member)) return false; if (!name || name === 'user' || isLeadMember(member)) return false;
return !member.removedAt; return !member.removedAt;
}).length; }).length;
@ -426,6 +442,8 @@ async function readDraftTeamMeta(
lastActivity: lastActivity:
typeof meta.createdAt === 'number' ? new Date(meta.createdAt).toISOString() : null, typeof meta.createdAt === 'number' ? new Date(meta.createdAt).toISOString() : null,
color: typeof meta.color === 'string' ? meta.color : undefined, color: typeof meta.color === 'string' ? meta.color : undefined,
...(leadName ? { leadName } : {}),
...(leadColor ? { leadColor } : {}),
projectPath: typeof meta.cwd === 'string' ? meta.cwd : undefined, projectPath: typeof meta.cwd === 'string' ? meta.cwd : undefined,
pendingCreate: true, pendingCreate: true,
}; };
@ -477,12 +495,12 @@ async function listTeams(
stat = await fs.promises.stat(configPath); stat = await fs.promises.stat(configPath);
} catch { } catch {
// Fallback: check for draft team (team.meta.json without config.json) // Fallback: check for draft team (team.meta.json without config.json)
const draft = await readDraftTeamMeta(payload.teamsDir, teamName); const draft = await readDraftTeamMeta(payload.teamsDir, teamName, payload);
if (draft) return draft; if (draft) return draft;
return skip('config_stat_failed'); return skip('config_stat_failed');
} }
if (!stat.isFile()) { if (!stat.isFile()) {
const draft = await readDraftTeamMeta(payload.teamsDir, teamName); const draft = await readDraftTeamMeta(payload.teamsDir, teamName, payload);
if (draft) return draft; if (draft) return draft;
return skip('config_not_file'); return skip('config_not_file');
} }
@ -557,6 +575,21 @@ async function listTeams(
removedAt?: unknown; removedAt?: unknown;
}[] = []; }[] = [];
let leadProviderId: 'anthropic' | 'codex' | 'gemini' | 'opencode' | undefined; let leadProviderId: 'anthropic' | 'codex' | 'gemini' | 'opencode' | undefined;
let leadName: string | undefined;
let leadColor: string | undefined;
const captureLeadMember = (member: RawMember, overwrite = false): void => {
if (member.removedAt) return;
if (!isLeadMember(member)) return;
const name = typeof member.name === 'string' ? member.name.trim() : '';
if (name && (overwrite || !leadName)) {
leadName = name;
}
const colorValue = typeof member.color === 'string' ? member.color.trim() : '';
if (colorValue && (overwrite || !leadColor)) {
leadColor = colorValue;
}
};
try { try {
const teamMetaPath = path.join(payload.teamsDir, teamName, 'team.meta.json'); const teamMetaPath = path.join(payload.teamsDir, teamName, 'team.meta.json');
@ -595,6 +628,7 @@ async function listTeams(
: undefined; : undefined;
const name = typeof member.name === 'string' ? member.name.trim() : ''; const name = typeof member.name === 'string' ? member.name.trim() : '';
if (!name) continue; if (!name) continue;
captureLeadMember(member);
if (isLeadMember(member)) continue; if (isLeadMember(member)) continue;
const key = name.toLowerCase(); const key = name.toLowerCase();
if (member.removedAt) { if (member.removedAt) {
@ -623,6 +657,7 @@ async function listTeams(
for (const member of config.members as unknown[]) { for (const member of config.members as unknown[]) {
if (isRawMember(member)) { if (isRawMember(member)) {
const name = typeof member.name === 'string' ? member.name.trim() : ''; const name = typeof member.name === 'string' ? member.name.trim() : '';
captureLeadMember(member, true);
if (name && name !== 'user' && !isLeadMember(member)) { if (name && name !== 'user' && !isLeadMember(member)) {
confirmedArtifactNames.add(name); confirmedArtifactNames.add(name);
} }
@ -691,6 +726,8 @@ async function listTeams(
taskCount: 0, taskCount: 0,
lastActivity: null, lastActivity: null,
...(coloredMembers.length > 0 ? { members: coloredMembers } : {}), ...(coloredMembers.length > 0 ? { members: coloredMembers } : {}),
...(leadName ? { leadName } : {}),
...(leadColor ? { leadColor } : {}),
...(color ? { color } : {}), ...(color ? { color } : {}),
...(projectPath ? { projectPath } : {}), ...(projectPath ? { projectPath } : {}),
...(leadSessionId ? { leadSessionId } : {}), ...(leadSessionId ? { leadSessionId } : {}),

View file

@ -55,6 +55,8 @@ export interface TeamSummary {
color?: string; color?: string;
memberCount: number; memberCount: number;
members?: TeamSummaryMember[]; members?: TeamSummaryMember[];
leadName?: string;
leadColor?: string;
taskCount: number; taskCount: number;
lastActivity: string | null; lastActivity: string | null;
projectPath?: string; projectPath?: string;

View file

@ -1,7 +1,7 @@
import fs from 'fs'; import fs from 'fs';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { import {
MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV, MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV,
@ -80,6 +80,34 @@ describe('createMemberWorkSyncFeature composition', () => {
} }
}); });
it('uses snapshot config reads for startup roster materialization', async () => {
const getConfig = vi.fn(async () => ({ members: [] }));
const getConfigSnapshot = vi.fn(async () => ({
members: [{ name: 'alice' }],
}));
const feature = createMemberWorkSyncFeature({
teamsBasePath: makeTempRoot(),
configReader: {
getConfig,
getConfigSnapshot,
} as never,
taskReader: {} as never,
kanbanManager: {} as never,
membersMetaStore: {
getMembers: vi.fn(async () => []),
} as never,
nudgeSideEffectsEnabled: false,
});
try {
await feature.enqueueStartupScan(['my-team']);
expect(getConfigSnapshot).toHaveBeenCalledWith('my-team');
expect(getConfig).not.toHaveBeenCalled();
} finally {
await feature.dispose();
}
});
it('builds Claude Stop hook settings without requiring nudge side effects', async () => { it('builds Claude Stop hook settings without requiring nudge side effects', async () => {
const root = makeTempRoot(); const root = makeTempRoot();
const feature = createMemberWorkSyncFeature({ const feature = createMemberWorkSyncFeature({

View file

@ -50,8 +50,29 @@ const { mockTeamDataWorkerClient } = vi.hoisted(() => ({
getMemberActivityMeta: vi.fn(), getMemberActivityMeta: vi.fn(),
findLogsForTask: vi.fn(), findLogsForTask: vi.fn(),
invalidateTeamConfig: vi.fn(), invalidateTeamConfig: vi.fn(),
invalidateTeamMessageFeed: vi.fn(),
}, },
})); }));
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 };
}
async function flushMicrotasks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
vi.mock('@main/services/infrastructure/NotificationManager', () => ({ vi.mock('@main/services/infrastructure/NotificationManager', () => ({
NotificationManager: { NotificationManager: {
getInstance: vi.fn().mockReturnValue({ getInstance: vi.fn().mockReturnValue({
@ -188,6 +209,8 @@ describe('ipc teams handlers', () => {
projectPath: '/tmp/project', projectPath: '/tmp/project',
})), })),
deleteTeam: vi.fn(async () => undefined), deleteTeam: vi.fn(async () => undefined),
restoreTeam: vi.fn(async () => undefined),
permanentlyDeleteTeam: vi.fn(async () => undefined),
getLeadMemberName: vi.fn(async () => 'team-lead'), getLeadMemberName: vi.fn(async () => 'team-lead'),
getTeamDisplayName: vi.fn(async () => 'My Team'), getTeamDisplayName: vi.fn(async () => 'My Team'),
updateConfig: vi.fn(async () => ({ name: 'My Team' })), updateConfig: vi.fn(async () => ({ name: 'My Team' })),
@ -315,6 +338,7 @@ describe('ipc teams handlers', () => {
mockTeamDataWorkerClient.getMemberActivityMeta.mockReset(); mockTeamDataWorkerClient.getMemberActivityMeta.mockReset();
mockTeamDataWorkerClient.findLogsForTask.mockReset(); mockTeamDataWorkerClient.findLogsForTask.mockReset();
mockTeamDataWorkerClient.invalidateTeamConfig.mockReset(); mockTeamDataWorkerClient.invalidateTeamConfig.mockReset();
mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset();
initializeTeamHandlers( initializeTeamHandlers(
service as never, service as never,
provisioningService as never, provisioningService as never,
@ -1089,6 +1113,137 @@ describe('ipc teams handlers', () => {
(electron.app as { isPackaged: boolean }).isPackaged = false; (electron.app as { isPackaged: boolean }).isPackaged = false;
}); });
it('classifies draft teams before asking the team-data worker for a full snapshot', async () => {
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-get-data-'));
setClaudeBasePathOverride(claudeRoot);
const teamDir = path.join(claudeRoot, 'teams', 'draft-team');
await fs.promises.mkdir(teamDir, { recursive: true });
await fs.promises.writeFile(
path.join(teamDir, 'team.meta.json'),
JSON.stringify({
version: 1,
cwd: '/tmp/draft-team',
createdAt: Date.now(),
})
);
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
try {
const handler = handlers.get(TEAM_GET_DATA)!;
const result = (await handler({} as never, 'draft-team')) as {
success: boolean;
error?: string;
};
expect(result).toEqual({ success: false, error: 'TEAM_DRAFT' });
expect(mockTeamDataWorkerClient.getTeamData).not.toHaveBeenCalled();
expect(service.getTeamData).not.toHaveBeenCalledWith('draft-team');
} finally {
await fs.promises.rm(claudeRoot, { recursive: true, force: true });
setClaudeBasePathOverride(null);
}
});
it('classifies draft teams before falling back to main-thread getTeamData', async () => {
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-main-get-data-'));
setClaudeBasePathOverride(claudeRoot);
const teamDir = path.join(claudeRoot, 'teams', 'draft-team');
await fs.promises.mkdir(teamDir, { recursive: true });
await fs.promises.writeFile(
path.join(teamDir, 'team.meta.json'),
JSON.stringify({
version: 1,
cwd: '/tmp/draft-team',
createdAt: Date.now(),
})
);
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
try {
const handler = handlers.get(TEAM_GET_DATA)!;
const result = (await handler({} as never, 'draft-team')) as {
success: boolean;
error?: string;
};
expect(result).toEqual({ success: false, error: 'TEAM_DRAFT' });
expect(mockTeamDataWorkerClient.getTeamData).not.toHaveBeenCalled();
expect(service.getTeamData).not.toHaveBeenCalledWith('draft-team');
} finally {
await fs.promises.rm(claudeRoot, { recursive: true, force: true });
setClaudeBasePathOverride(null);
}
});
it('does not let slow draft metadata classification block normal getData fallback', async () => {
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-slow-meta-'));
setClaudeBasePathOverride(claudeRoot);
const teamDir = path.join(claudeRoot, 'teams', 'slow-meta-team');
await fs.promises.mkdir(teamDir, { recursive: true });
const { TeamMetaStore } = await import('../../../src/main/services/team/TeamMetaStore');
const metaSpy = vi
.spyOn(TeamMetaStore.prototype, 'getMeta')
.mockImplementation(async () => new Promise(() => undefined));
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
mockTeamDataWorkerClient.getTeamData.mockResolvedValueOnce({
teamName: 'slow-meta-team',
config: { name: 'Slow Meta Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'slow-meta-team', reviewers: [], tasks: {} },
processes: [],
});
try {
const startedAt = Date.now();
const handler = handlers.get(TEAM_GET_DATA)!;
const result = (await handler({} as never, 'slow-meta-team')) as {
success: boolean;
data?: { teamName: string };
};
expect(Date.now() - startedAt).toBeLessThan(1500);
expect(result.success).toBe(true);
expect(result.data?.teamName).toBe('slow-meta-team');
expect(mockTeamDataWorkerClient.getTeamData).toHaveBeenCalledWith('slow-meta-team');
} finally {
metaSpy.mockRestore();
await fs.promises.rm(claudeRoot, { recursive: true, force: true });
setClaudeBasePathOverride(null);
}
});
it('does not let slow draft metadata classification block Team not found fallback', async () => {
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-slow-missing-meta-'));
setClaudeBasePathOverride(claudeRoot);
const teamDir = path.join(claudeRoot, 'teams', 'slow-missing-team');
await fs.promises.mkdir(teamDir, { recursive: true });
const { TeamMetaStore } = await import('../../../src/main/services/team/TeamMetaStore');
const metaSpy = vi
.spyOn(TeamMetaStore.prototype, 'getMeta')
.mockImplementation(async () => new Promise(() => undefined));
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
service.getTeamData.mockRejectedValueOnce(new Error('Team not found: slow-missing-team'));
try {
const startedAt = Date.now();
const handler = handlers.get(TEAM_GET_DATA)!;
const result = (await handler({} as never, 'slow-missing-team')) as {
success: boolean;
error?: string;
};
expect(Date.now() - startedAt).toBeLessThan(1500);
expect(result).toEqual({ success: false, error: 'Team not found: slow-missing-team' });
expect(service.getTeamData).toHaveBeenCalledWith('slow-missing-team');
vi.mocked(console.error).mockClear();
} finally {
metaSpy.mockRestore();
await fs.promises.rm(claudeRoot, { recursive: true, force: true });
setClaudeBasePathOverride(null);
}
});
it('does not let a live duplicate of the same session rate-limit reply delay auto-resume', async () => { it('does not let a live duplicate of the same session rate-limit reply delay auto-resume', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-17T12:00:30.000Z')); vi.setSystemTime(new Date('2026-04-17T12:00:30.000Z'));
@ -1221,6 +1376,7 @@ describe('ipc teams handlers', () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.data.feedRevision).toBe('rev-worker'); expect(result.data.feedRevision).toBe('rev-worker');
await flushMicrotasks();
expect(mockAddTeamNotification).toHaveBeenCalledWith( expect(mockAddTeamNotification).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
teamEventType: 'rate_limit', teamEventType: 'rate_limit',
@ -1233,6 +1389,47 @@ describe('ipc teams handlers', () => {
expect(service.getMessageFeed).not.toHaveBeenCalled(); expect(service.getMessageFeed).not.toHaveBeenCalled();
}); });
it('does not block TEAM_GET_MESSAGES_PAGE on notification context reads', async () => {
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({
messages: [
{
from: 'team-lead',
text: "You've hit your limit. Please wait a bit before retrying.",
timestamp: '2026-02-23T10:00:01.000Z',
read: true,
source: 'lead_session' as const,
messageId: 'msg-rate-limit-nonblocking',
},
],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-worker',
});
const context = createDeferred<{ displayName: string; projectPath: string }>();
service.getTeamNotificationContext.mockReturnValueOnce(context.promise);
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
const result = (await handler({} as never, 'my-team', {
limit: 50,
})) as { success: boolean; data: { feedRevision: string } };
expect(result.success).toBe(true);
expect(result.data.feedRevision).toBe('rev-worker');
expect(mockAddTeamNotification).not.toHaveBeenCalled();
context.resolve({ displayName: 'My Team', projectPath: '/tmp/project' });
await flushMicrotasks();
expect(mockAddTeamNotification).toHaveBeenCalledWith(
expect.objectContaining({
teamEventType: 'rate_limit',
teamName: 'my-team',
teamDisplayName: 'My Team',
dedupeKey: 'rate-limit:my-team:msg-rate-limit-nonblocking',
})
);
});
it('falls back TEAM_GET_MESSAGES_PAGE to the main thread in packaged runtime when worker is unavailable', async () => { it('falls back TEAM_GET_MESSAGES_PAGE to the main thread in packaged runtime when worker is unavailable', async () => {
const electron = await import('electron'); const electron = await import('electron');
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false); mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
@ -2130,6 +2327,7 @@ describe('ipc teams handlers', () => {
description: undefined, description: undefined,
color: undefined, color: undefined,
}); });
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith( expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team', 'my-team',
'The team has been renamed to "Renamed Team". Please use this name when referring to the team going forward.' 'The team has been renamed to "Renamed Team". Please use this name when referring to the team going forward.'
@ -2155,6 +2353,33 @@ describe('ipc teams handlers', () => {
}); });
}); });
describe('team mutation cache invalidation', () => {
it('invalidates worker config cache after delete, restore, and permanent delete', async () => {
const deleteHandler = handlers.get(TEAM_DELETE_TEAM)!;
const restoreHandler = handlers.get(TEAM_RESTORE)!;
const permanentlyDeleteHandler = handlers.get(TEAM_PERMANENTLY_DELETE)!;
let result = (await deleteHandler({} as never, 'my-team')) as { success: boolean };
expect(result.success).toBe(true);
expect(service.deleteTeam).toHaveBeenCalledWith('my-team');
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
mockTeamDataWorkerClient.invalidateTeamConfig.mockClear();
result = (await restoreHandler({} as never, 'my-team')) as { success: boolean };
expect(result.success).toBe(true);
expect(service.restoreTeam).toHaveBeenCalledWith('my-team');
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
mockTeamDataWorkerClient.invalidateTeamConfig.mockClear();
result = (await permanentlyDeleteHandler({} as never, 'my-team')) as { success: boolean };
expect(result.success).toBe(true);
expect(service.permanentlyDeleteTeam).toHaveBeenCalledWith('my-team');
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
});
});
describe('removeMember', () => { describe('removeMember', () => {
it('calls service on valid input', async () => { it('calls service on valid input', async () => {
const handler = handlers.get(TEAM_REMOVE_MEMBER)!; const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
@ -2908,6 +3133,7 @@ describe('ipc teams handlers', () => {
cwd: os.tmpdir(), cwd: os.tmpdir(),
})) as { success: boolean }; })) as { success: boolean };
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('solo-team');
}); });
it('handleCreateConfig preserves draft launch metadata', async () => { it('handleCreateConfig preserves draft launch metadata', async () => {

View file

@ -191,6 +191,24 @@ describe('FileWatcher', () => {
]); ]);
}); });
it('emits config team-change events for team and members metadata changes', () => {
const dataCache = new DataCache(50, 10, false);
const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos');
const events: unknown[] = [];
watcher.on('team-change', (event) => events.push(event));
const testWatcher = watcher as unknown as {
processTeamsChange: (eventType: string, filename: string) => void;
};
testWatcher.processTeamsChange('change', 'team-a/team.meta.json');
testWatcher.processTeamsChange('change', 'team-a/members.meta.json');
expect(events).toEqual([
{ type: 'config', teamName: 'team-a', detail: 'team.meta.json' },
{ type: 'config', teamName: 'team-a', detail: 'members.meta.json' },
]);
});
it('keeps append offset pinned for partial trailing lines until completed', async () => { it('keeps append offset pinned for partial trailing lines until completed', async () => {
vi.useRealTimers(); vi.useRealTimers();
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-')); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-'));

View file

@ -51,7 +51,10 @@ function makeConfig(overrides: Partial<TeamConfig> = {}): TeamConfig {
describe('CrossTeamService', () => { describe('CrossTeamService', () => {
let service: CrossTeamService; let service: CrossTeamService;
let configReader: { getConfig: ReturnType<typeof vi.fn> }; let configReader: { getConfig: ReturnType<typeof vi.fn> };
let dataService: { getLeadMemberName: ReturnType<typeof vi.fn> }; let dataService: {
getLeadMemberName: ReturnType<typeof vi.fn>;
listTeams: ReturnType<typeof vi.fn>;
};
let inboxWriter: { sendMessage: ReturnType<typeof vi.fn> }; let inboxWriter: { sendMessage: ReturnType<typeof vi.fn> };
let provisioning: { let provisioning: {
isTeamAlive: ReturnType<typeof vi.fn>; isTeamAlive: ReturnType<typeof vi.fn>;
@ -68,6 +71,7 @@ describe('CrossTeamService', () => {
}; };
dataService = { dataService = {
getLeadMemberName: vi.fn().mockResolvedValue('team-lead'), getLeadMemberName: vi.fn().mockResolvedValue('team-lead'),
listTeams: vi.fn().mockResolvedValue([]),
}; };
inboxWriter = { inboxWriter = {
sendMessage: vi.fn().mockResolvedValue({ deliveredToInbox: true, messageId: 'mock-id' }), sendMessage: vi.fn().mockResolvedValue({ deliveredToInbox: true, messageId: 'mock-id' }),
@ -353,11 +357,65 @@ describe('CrossTeamService', () => {
}); });
describe('listAvailableTargets', () => { describe('listAvailableTargets', () => {
it('returns empty when teams dir read fails', async () => { it('returns empty when team summary listing fails', async () => {
configReader.getConfig.mockRejectedValue(new Error('ENOENT')); dataService.listTeams.mockRejectedValue(new Error('ENOENT'));
const result = await service.listAvailableTargets(); const result = await service.listAvailableTargets();
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
it('uses team summaries instead of verified config reads for target discovery', async () => {
dataService.listTeams.mockResolvedValue([
{
teamName: 'team-a',
displayName: 'Team A',
description: '',
memberCount: 1,
members: [],
},
{
teamName: 'team-b',
displayName: 'Team B',
description: 'Target team',
color: 'blue',
memberCount: 1,
members: [{ name: 'alice', color: '#abcdef' }],
leadName: 'captain',
leadColor: '#123456',
},
{
teamName: 'deleted-team',
displayName: 'Deleted',
description: '',
memberCount: 0,
members: [],
deletedAt: '2026-05-01T00:00:00.000Z',
},
{
teamName: 'draft-team',
displayName: 'Draft',
description: '',
memberCount: 0,
members: [],
pendingCreate: true,
},
]);
provisioning.isTeamAlive.mockImplementation((teamName: string) => teamName === 'team-b');
const result = await service.listAvailableTargets('team-a');
expect(configReader.getConfig).not.toHaveBeenCalled();
expect(result).toEqual([
{
teamName: 'team-b',
displayName: 'Team B',
description: 'Target team',
color: 'blue',
leadName: 'captain',
leadColor: '#123456',
isOnline: true,
},
]);
});
}); });
describe('getOutbox', () => { describe('getOutbox', () => {

View file

@ -200,6 +200,118 @@ describe('TeamConfigReader', () => {
expect(teams[0]?.missingMembers).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 () => { it('does not let a removed base member hide an active auto-suffixed teammate in team summaries', async () => {
const teamName = 'suffix-team'; const teamName = 'suffix-team';
const teamDir = path.join(tempDir, teamName); const teamDir = path.join(tempDir, teamName);
@ -254,7 +366,7 @@ describe('TeamConfigReader', () => {
JSON.stringify({ JSON.stringify({
version: 1, version: 1,
members: [ members: [
{ name: 'team-lead', agentType: 'team-lead' }, { name: 'team-lead', agentType: 'team-lead', color: '#123456' },
{ name: 'alice', removedAt: Date.now() - 60_000 }, { name: 'alice', removedAt: Date.now() - 60_000 },
{ name: 'bob', role: 'developer' }, { name: 'bob', role: 'developer' },
], ],
@ -269,6 +381,46 @@ describe('TeamConfigReader', () => {
teamName, teamName,
displayName: 'Draft Summary Team', displayName: 'Draft Summary Team',
memberCount: 1, 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, pendingCreate: true,
}); });
}); });
@ -332,6 +484,59 @@ describe('TeamConfigReader', () => {
expect(readFileSpy).toHaveBeenCalledTimes(1); 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 () => { it('shares in-flight snapshot stat and read work for concurrent calls', async () => {
const teamName = 'snapshot-inflight-team'; const teamName = 'snapshot-inflight-team';
const teamDir = path.join(tempDir, teamName); const teamDir = path.join(tempDir, teamName);
@ -511,6 +716,54 @@ describe('TeamConfigReader', () => {
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Fresh Prime'); 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 () => { it('does not let stale in-flight snapshot read failures invalidate a primed config cache', async () => {
const teamName = 'stale-read-failure-prime-team'; const teamName = 'stale-read-failure-prime-team';
const teamDir = path.join(tempDir, teamName); const teamDir = path.join(tempDir, teamName);

View file

@ -9,6 +9,7 @@ import { encodePath, setClaudeBasePathOverride } from '../../../../src/main/util
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader'; import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils'; import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils';
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService'; import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
import type { TeamMetaFile } from '../../../../src/main/services/team/TeamMetaStore'; import type { TeamMetaFile } from '../../../../src/main/services/team/TeamMetaStore';
import type { import type {
@ -237,6 +238,109 @@ afterEach(async () => {
); );
}); });
describe('TeamDataService task projection cache invalidation', () => {
it('invalidates global task projection cache after direct task mutations', async () => {
const task: TeamTask = {
id: 'task-1',
subject: 'Task 1',
status: 'pending',
createdAt: '2026-05-02T12:00:00.000Z',
updatedAt: '2026-05-02T12:00:00.000Z',
};
const taskController = {
createTask: vi.fn(() => task),
startTask: vi.fn(),
setTaskStatus: vi.fn(),
softDeleteTask: vi.fn(),
restoreTask: vi.fn(),
setTaskOwner: vi.fn(),
updateTaskFields: vi.fn(),
addTaskAttachmentMeta: vi.fn(),
removeTaskAttachment: vi.fn(),
setNeedsClarification: vi.fn(),
linkTask: vi.fn(),
unlinkTask: vi.fn(),
addTaskComment: vi.fn(() => ({
comment: {
id: 'comment-1',
author: 'user',
text: 'Comment',
createdAt: '2026-05-02T12:01:00.000Z',
type: 'regular',
},
})),
};
const service = new TeamDataService(
{
getConfig: vi.fn(async () => ({
name: 'my-team',
projectPath: '/repo',
members: [{ name: 'team-lead', role: 'Lead' }],
})),
} as never,
{
getTasks: vi.fn(async () => [task]),
} as never,
{
listInboxNames: vi.fn(async () => []),
getMessages: vi.fn(async () => []),
} as never,
{} as never,
{} as never,
{ resolveMembers: vi.fn(() => []) } as never,
{ getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })) } as never,
{} as never,
{ getMembers: vi.fn(async () => []) } as never,
{ readMessages: vi.fn(async () => []) } as never,
(() => ({ tasks: taskController })) as never
);
const invalidateSpy = vi.spyOn(TeamTaskReader, 'invalidateAllTasksCache');
await service.createTask('my-team', { subject: 'Task 1' });
await service.startTask('my-team', 'task-1');
await service.startTaskByUser('my-team', 'task-1');
await service.updateTaskStatus('my-team', 'task-1', 'completed');
await service.softDeleteTask('my-team', 'task-1');
await service.restoreTask('my-team', 'task-1');
await service.updateTaskOwner('my-team', 'task-1', 'alice');
await service.updateTaskFields('my-team', 'task-1', { subject: 'Task 1 updated' });
await service.addTaskAttachment('my-team', 'task-1', {
id: 'att-1',
filename: 'note.txt',
mimeType: 'text/plain',
size: 1,
createdAt: '2026-05-02T12:02:00.000Z',
} as never);
await service.removeTaskAttachment('my-team', 'task-1', 'att-1');
await service.setTaskNeedsClarification('my-team', 'task-1', 'lead');
await service.addTaskRelationship('my-team', 'task-1', 'task-2', 'related');
await service.removeTaskRelationship('my-team', 'task-1', 'task-2', 'related');
await service.addTaskComment('my-team', 'task-1', 'Comment');
expect(invalidateSpy).toHaveBeenCalledTimes(14);
});
it('invalidates config and global task caches after permanent team deletion', async () => {
const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-data-delete-cache-'));
tempPaths.push(claudeRoot);
setClaudeBasePathOverride(claudeRoot);
await fs.mkdir(path.join(claudeRoot, 'teams', 'gone-team'), { recursive: true });
await fs.mkdir(path.join(claudeRoot, 'tasks', 'gone-team'), { recursive: true });
const configInvalidateSpy = vi.spyOn(TeamConfigReader, 'invalidateTeam');
const taskInvalidateSpy = vi.spyOn(TeamTaskReader, 'invalidateAllTasksCache');
const service = new TeamDataService();
await service.permanentlyDeleteTeam('gone-team');
await expect(fs.access(path.join(claudeRoot, 'teams', 'gone-team'))).rejects.toThrow();
await expect(fs.access(path.join(claudeRoot, 'tasks', 'gone-team'))).rejects.toThrow();
expect(configInvalidateSpy).toHaveBeenCalledWith('gone-team');
expect(taskInvalidateSpy).toHaveBeenCalledTimes(1);
});
});
describe('TeamDataService draft metadata', () => { describe('TeamDataService draft metadata', () => {
it('round-trips create config metadata through getSavedRequest', async () => { it('round-trips create config metadata through getSavedRequest', async () => {
const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-data-saved-request-')); const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-data-saved-request-'));
@ -244,6 +348,7 @@ describe('TeamDataService draft metadata', () => {
setClaudeBasePathOverride(claudeRoot); setClaudeBasePathOverride(claudeRoot);
const service = new TeamDataService(); const service = new TeamDataService();
const listCacheInvalidateSpy = vi.spyOn(TeamConfigReader, 'invalidateListTeamsCache');
await service.createTeamConfig({ await service.createTeamConfig({
teamName: 'draft-team', teamName: 'draft-team',
displayName: 'Draft Team', displayName: 'Draft Team',
@ -271,6 +376,7 @@ describe('TeamDataService draft metadata', () => {
}, },
], ],
}); });
expect(listCacheInvalidateSpy).toHaveBeenCalled();
await expect(service.getSavedRequest('missing-team')).resolves.toBeNull(); await expect(service.getSavedRequest('missing-team')).resolves.toBeNull();
await expect(service.getSavedRequest('draft-team')).resolves.toMatchObject({ await expect(service.getSavedRequest('draft-team')).resolves.toMatchObject({
@ -1319,11 +1425,17 @@ describe('TeamDataService', () => {
projectPath: '/Users/dev/my-project', projectPath: '/Users/dev/my-project',
members: [], members: [],
})); }));
const getConfigSnapshot = vi.fn(async () => ({
name: 'My Team',
projectPath: '/Users/dev/my-project',
members: [],
}));
const service = new TeamDataService( const service = new TeamDataService(
{ {
listTeams: vi.fn(), listTeams: vi.fn(),
getConfig, getConfig,
getConfigSnapshot,
} as never, } as never,
{} as never, {} as never,
{} as never, {} as never,
@ -1343,7 +1455,8 @@ describe('TeamDataService', () => {
displayName: 'My Team', displayName: 'My Team',
projectPath: '/Users/dev/my-project', projectPath: '/Users/dev/my-project',
}); });
expect(getConfig).toHaveBeenCalledWith('my-team'); expect(getConfigSnapshot).toHaveBeenCalledWith('my-team');
expect(getConfig).not.toHaveBeenCalled();
}); });
it('creates task with status pending when startImmediately is false', async () => { it('creates task with status pending when startImmediately is false', async () => {

View file

@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => { const hoisted = vi.hoisted(() => {
const skipResponsesForOps = new Set<string>();
const workers: Array<{ const workers: Array<{
messages: unknown[]; messages: unknown[];
handlers: Map<string, (value: unknown) => void>; handlers: Map<string, (value: unknown) => void>;
@ -15,6 +16,7 @@ const hoisted = vi.hoisted(() => {
postMessage(message: unknown) { postMessage(message: unknown) {
worker.messages.push(message); worker.messages.push(message);
const request = message as { id: string; op: string; payload?: { teamName?: string } }; const request = message as { id: string; op: string; payload?: { teamName?: string } };
if (skipResponsesForOps.has(request.op)) return;
queueMicrotask(() => { queueMicrotask(() => {
const handler = worker.handlers.get('message'); const handler = worker.handlers.get('message');
if (!handler) return; if (!handler) return;
@ -24,6 +26,8 @@ const hoisted = vi.hoisted(() => {
result: result:
request.op === 'getTeamData' request.op === 'getTeamData'
? { teamName: request.payload?.teamName, config: { name: 'Team' } } ? { teamName: request.payload?.teamName, config: { name: 'Team' } }
: request.op === 'getMessagesPage'
? { messages: [], nextCursor: null, hasMore: false, feedRevision: 'rev-1' }
: null, : null,
}); });
}); });
@ -39,6 +43,7 @@ const hoisted = vi.hoisted(() => {
return { return {
workers, workers,
createMockWorker, createMockWorker,
skipResponsesForOps,
}; };
}); });
@ -61,7 +66,9 @@ describe('TeamDataWorkerClient', () => {
afterEach(() => { afterEach(() => {
vi.resetModules(); vi.resetModules();
vi.clearAllMocks(); vi.clearAllMocks();
vi.useRealTimers();
hoisted.workers.length = 0; hoisted.workers.length = 0;
hoisted.skipResponsesForOps.clear();
}); });
it('deduplicates concurrent getTeamData calls for the same team', async () => { it('deduplicates concurrent getTeamData calls for the same team', async () => {
@ -107,6 +114,71 @@ describe('TeamDataWorkerClient', () => {
client.dispose(); client.dispose();
}); });
it('deduplicates concurrent getMessagesPage calls with the same page key', async () => {
const { TeamDataWorkerClient } = await import(
'../../../../src/main/services/team/TeamDataWorkerClient'
);
const client = new TeamDataWorkerClient();
const [first, second] = await Promise.all([
client.getMessagesPage('my-team', { cursor: null, limit: 50 }),
client.getMessagesPage('my-team', { cursor: null, limit: 50 }),
]);
expect(first).toEqual(second);
expect(hoisted.workers).toHaveLength(1);
expect(hoisted.workers[0].messages).toHaveLength(1);
expect(hoisted.workers[0].messages[0]).toMatchObject({
op: 'getMessagesPage',
payload: { teamName: 'my-team', options: { cursor: null, limit: 50 } },
});
client.dispose();
});
it('sends best-effort message feed invalidation to the worker', async () => {
const { TeamDataWorkerClient } = await import(
'../../../../src/main/services/team/TeamDataWorkerClient'
);
const client = new TeamDataWorkerClient();
await client.getTeamData('my-team');
hoisted.workers[0].messages.length = 0;
client.invalidateTeamMessageFeed('my-team');
await new Promise((resolve) => setTimeout(resolve, 0));
expect(hoisted.workers).toHaveLength(1);
expect(hoisted.workers[0].messages).toHaveLength(1);
expect(hoisted.workers[0].messages[0]).toMatchObject({
op: 'invalidateTeamMessageFeed',
payload: { teamName: 'my-team' },
});
client.dispose();
});
it('clears in-flight getMessagesPage dedupe when invalidating message feed', async () => {
const { TeamDataWorkerClient } = await import(
'../../../../src/main/services/team/TeamDataWorkerClient'
);
const client = new TeamDataWorkerClient();
const first = client.getMessagesPage('my-team', { cursor: null, limit: 50 });
client.invalidateTeamMessageFeed('my-team');
const second = client.getMessagesPage('my-team', { cursor: null, limit: 50 });
await Promise.all([first, second]);
expect(hoisted.workers).toHaveLength(1);
expect(hoisted.workers[0].messages.map((message) => (message as { op: string }).op)).toEqual([
'getMessagesPage',
'invalidateTeamMessageFeed',
'getMessagesPage',
]);
client.dispose();
});
it('clears in-flight getTeamData dedupe when invalidating team config', async () => { it('clears in-flight getTeamData dedupe when invalidating team config', async () => {
const { TeamDataWorkerClient } = await import( const { TeamDataWorkerClient } = await import(
'../../../../src/main/services/team/TeamDataWorkerClient' '../../../../src/main/services/team/TeamDataWorkerClient'
@ -140,4 +212,23 @@ describe('TeamDataWorkerClient', () => {
expect(hoisted.workers).toHaveLength(0); expect(hoisted.workers).toHaveLength(0);
}); });
it('does not attach a timeout that can kill the worker for best-effort invalidation', async () => {
vi.useFakeTimers();
const { TeamDataWorkerClient } = await import(
'../../../../src/main/services/team/TeamDataWorkerClient'
);
const client = new TeamDataWorkerClient();
await client.getTeamData('my-team');
hoisted.workers[0].messages.length = 0;
hoisted.skipResponsesForOps.add('invalidateTeamMessageFeed');
client.invalidateTeamMessageFeed('my-team');
await vi.advanceTimersByTimeAsync(31_000);
expect(hoisted.workers[0].messages).toHaveLength(1);
expect(hoisted.workers[0].terminate).not.toHaveBeenCalled();
client.dispose();
});
}); });

View file

@ -1,4 +1,3 @@
import { existsSync } from 'fs';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
@ -18,11 +17,6 @@ interface WorkerResponse {
let bundledWorkerPathPromise: Promise<string> | null = null; let bundledWorkerPathPromise: Promise<string> | null = null;
async function getWorkerPath(): Promise<string> { async function getWorkerPath(): Promise<string> {
const builtWorkerPath = path.join(process.cwd(), 'dist-electron', 'main', 'team-fs-worker.cjs');
if (existsSync(builtWorkerPath)) {
return builtWorkerPath;
}
bundledWorkerPathPromise ??= bundleWorkerForTests(); bundledWorkerPathPromise ??= bundleWorkerForTests();
return bundledWorkerPathPromise; return bundledWorkerPathPromise;
} }
@ -230,7 +224,7 @@ describe('team-fs-worker integration', () => {
JSON.stringify({ JSON.stringify({
version: 1, version: 1,
members: [ members: [
{ name: 'team-lead', agentType: 'team-lead' }, { name: 'team-lead', agentType: 'team-lead', color: '#123456' },
{ name: 'alice', removedAt: Date.parse('2026-04-22T12:01:00.000Z') }, { name: 'alice', removedAt: Date.parse('2026-04-22T12:01:00.000Z') },
{ name: 'bob', role: 'developer' }, { name: 'bob', role: 'developer' },
], ],
@ -246,6 +240,8 @@ describe('team-fs-worker integration', () => {
teamName, teamName,
displayName: 'Draft Worker Team', displayName: 'Draft Worker Team',
memberCount: 1, memberCount: 1,
leadName: 'team-lead',
leadColor: '#123456',
}); });
} finally { } finally {
await worker.terminate(); await worker.terminate();

View file

@ -17,6 +17,16 @@ function makeMessage(overrides: Partial<InboxMessage> = {}): InboxMessage {
}; };
} }
function createDeferred<T>() {
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('TeamMessageFeedService', () => { describe('TeamMessageFeedService', () => {
const config: TeamConfig = { const config: TeamConfig = {
name: 'Signal Ops 4', name: 'Signal Ops 4',
@ -101,6 +111,75 @@ describe('TeamMessageFeedService', () => {
).toBe(true); ).toBe(true);
}); });
it('deduplicates concurrent feed rebuilds for the same team', async () => {
const inboxRequest = createDeferred<InboxMessage[]>();
const getInboxMessages = vi.fn(() => inboxRequest.promise);
const service = new TeamMessageFeedService({
getConfig: vi.fn(async () => config),
getInboxMessages,
getLeadSessionMessages: vi.fn(async () => []),
getSentMessages: vi.fn(async () => []),
});
const first = service.getFeed('signal-ops-4');
const second = service.getFeed('signal-ops-4');
await Promise.resolve();
expect(getInboxMessages).toHaveBeenCalledTimes(1);
inboxRequest.resolve([makeMessage()]);
const [firstFeed, secondFeed] = await Promise.all([first, second]);
expect(firstFeed).toEqual(secondFeed);
expect(firstFeed.messages).toHaveLength(1);
});
it('does not reuse or cache a stale in-flight rebuild after invalidation', async () => {
const firstInboxRequest = createDeferred<InboxMessage[]>();
const secondInboxRequest = createDeferred<InboxMessage[]>();
const getInboxMessages = vi
.fn()
.mockImplementationOnce(() => firstInboxRequest.promise)
.mockImplementationOnce(() => secondInboxRequest.promise);
const service = new TeamMessageFeedService({
getConfig: vi.fn(async () => config),
getInboxMessages,
getLeadSessionMessages: vi.fn(async () => []),
getSentMessages: vi.fn(async () => []),
});
const staleRequest = service.getFeed('signal-ops-4');
await Promise.resolve();
expect(getInboxMessages).toHaveBeenCalledTimes(1);
service.invalidate('signal-ops-4');
const freshRequest = service.getFeed('signal-ops-4');
await Promise.resolve();
expect(getInboxMessages).toHaveBeenCalledTimes(2);
secondInboxRequest.resolve([
makeMessage({
messageId: 'fresh-message',
text: 'fresh',
timestamp: '2026-04-19T18:46:45.000Z',
}),
]);
const freshFeed = await freshRequest;
expect(freshFeed.messages[0]?.messageId).toBe('fresh-message');
firstInboxRequest.resolve([
makeMessage({
messageId: 'stale-message',
text: 'stale',
timestamp: '2026-04-19T18:46:44.000Z',
}),
]);
await staleRequest;
const cachedFeed = await service.getFeed('signal-ops-4');
expect(cachedFeed.messages[0]?.messageId).toBe('fresh-message');
expect(getInboxMessages).toHaveBeenCalledTimes(2);
});
it('adds UI-only OpenCode bootstrap start rows for side-lane teammates', async () => { it('adds UI-only OpenCode bootstrap start rows for side-lane teammates', async () => {
const opencodeConfig: TeamConfig = { const opencodeConfig: TeamConfig = {
name: 'relay-works-14', name: 'relay-works-14',

View file

@ -131,6 +131,7 @@ import {
import { getTeamBootstrapStatePath } from '@main/services/team/TeamBootstrapStateReader'; import { getTeamBootstrapStatePath } from '@main/services/team/TeamBootstrapStateReader';
import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator'; import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore'; import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore';
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
import { import {
getOpenCodeLaneScopedRuntimeFilePath, getOpenCodeLaneScopedRuntimeFilePath,
getOpenCodeRuntimeManifestPath, getOpenCodeRuntimeManifestPath,
@ -8709,6 +8710,38 @@ describe('TeamProvisioningService', () => {
}; };
} }
it('invalidates config cache after writing OpenCode team config', async () => {
const teamName = 'opencode-config-cache-prime';
fs.mkdirSync(path.join(tempTeamsBase, teamName), { recursive: true });
const invalidateSpy = vi.spyOn(TeamConfigReader, 'invalidateTeam');
const { svc } = createSafeLaunchService();
await (svc as any).writeOpenCodeTeamConfig(
{
teamName,
displayName: 'OpenCode Config Cache Prime',
cwd: tempClaudeRoot,
providerId: 'opencode',
model: 'openrouter/test/model',
effort: 'medium',
},
[
{
name: 'bob',
role: 'Developer',
providerId: 'opencode',
model: 'openrouter/test/model',
},
]
);
expect(invalidateSpy).toHaveBeenCalledWith(teamName);
expect((await new TeamConfigReader().getConfigSnapshot(teamName))?.name).toBe(
'OpenCode Config Cache Prime'
);
invalidateSpy.mockRestore();
});
it('starts a pure Codex team through the app createTeam path without a real CLI process', async () => { it('starts a pure Codex team through the app createTeam path without a real CLI process', async () => {
allowConsoleLogs(); allowConsoleLogs();
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
@ -9158,6 +9191,7 @@ describe('TeamProvisioningService', () => {
const teamName = 'mixed-opencode-post-launch-config'; const teamName = 'mixed-opencode-post-launch-config';
const teamDir = path.join(tempTeamsBase, teamName); const teamDir = path.join(tempTeamsBase, teamName);
const jackWorktree = path.join(tempClaudeRoot, 'worktrees', 'jack'); const jackWorktree = path.join(tempClaudeRoot, 'worktrees', 'jack');
const invalidateSpy = vi.spyOn(TeamConfigReader, 'invalidateTeam');
fs.mkdirSync(teamDir, { recursive: true }); fs.mkdirSync(teamDir, { recursive: true });
fs.writeFileSync( fs.writeFileSync(
path.join(teamDir, 'config.json'), path.join(teamDir, 'config.json'),
@ -9228,6 +9262,7 @@ describe('TeamProvisioningService', () => {
expect(config.leadSessionId).toBe('new-lead-session'); expect(config.leadSessionId).toBe('new-lead-session');
expect(config.projectPath).toBe(tempClaudeRoot); expect(config.projectPath).toBe(tempClaudeRoot);
expect(invalidateSpy).toHaveBeenCalledWith(teamName);
expect(config.members).toEqual([ expect(config.members).toEqual([
expect.objectContaining({ expect.objectContaining({
name: 'team-lead', name: 'team-lead',
@ -9255,6 +9290,7 @@ describe('TeamProvisioningService', () => {
}), }),
]); ]);
expect(config.members.some((member) => member.name === 'alice')).toBe(false); expect(config.members.some((member) => member.name === 'alice')).toBe(false);
invalidateSpy.mockRestore();
}); });
it('launches isolated OpenCode side lanes from the resolved member worktree cwd', async () => { it('launches isolated OpenCode side lanes from the resolved member worktree cwd', async () => {

View file

@ -19,6 +19,15 @@ const hoisted = vi.hoisted(() => {
return { return {
isFile: () => true, isFile: () => true,
size: Buffer.byteLength(data, 'utf8'), size: Buffer.byteLength(data, 'utf8'),
mode: 0o100644,
dev: 1,
ino: 1,
mtimeMs: 1,
ctimeMs: 1,
birthtimeMs: 1,
mtimeNs: 1n,
ctimeNs: 1n,
birthtimeNs: 1n,
}; };
}); });
@ -2207,10 +2216,12 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
delivered: true, delivered: true,
diagnostics: [], diagnostics: [],
}); });
const recipientSpy = vi.spyOn(service, 'isOpenCodeRuntimeRecipient');
const relay = await service.relayInboxFileToLiveRecipient(teamName, 'jack'); const relay = await service.relayInboxFileToLiveRecipient(teamName, 'jack');
expect(relay).toMatchObject({ kind: 'opencode_member', relayed: 1 }); expect(relay).toMatchObject({ kind: 'opencode_member', relayed: 1 });
expect(recipientSpy).toHaveBeenCalledTimes(1);
const rows = JSON.parse( const rows = JSON.parse(
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
); );

View file

@ -0,0 +1,62 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
import type { TeamTask } from '../../../../src/shared/types/team';
function createDeferred<T>() {
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 };
}
function makeTask(id: string): TeamTask & { teamName: string } {
return {
id,
subject: id,
owner: 'alice',
status: 'pending',
createdAt: '2026-05-02T12:00:00.000Z',
updatedAt: '2026-05-02T12:00:00.000Z',
teamName: 'atlas-hq',
};
}
describe('TeamTaskReader', () => {
afterEach(() => {
vi.restoreAllMocks();
TeamTaskReader.invalidateAllTasksCache();
});
it('does not reuse or cache a stale in-flight getAllTasks scan after invalidation', async () => {
const firstRead = createDeferred<(TeamTask & { teamName: string })[]>();
const secondRead = createDeferred<(TeamTask & { teamName: string })[]>();
const readAllTasksUncached = vi
.spyOn(TeamTaskReader.prototype as unknown as { readAllTasksUncached: () => Promise<(TeamTask & { teamName: string })[]> }, 'readAllTasksUncached')
.mockImplementationOnce(() => firstRead.promise)
.mockImplementationOnce(() => secondRead.promise);
const reader = new TeamTaskReader();
const staleRequest = reader.getAllTasks();
await Promise.resolve();
expect(readAllTasksUncached).toHaveBeenCalledTimes(1);
TeamTaskReader.invalidateAllTasksCache();
const freshRequest = reader.getAllTasks();
await Promise.resolve();
expect(readAllTasksUncached).toHaveBeenCalledTimes(2);
secondRead.resolve([makeTask('fresh-task')]);
await expect(freshRequest).resolves.toEqual([makeTask('fresh-task')]);
firstRead.resolve([makeTask('stale-task')]);
await staleRequest;
await expect(reader.getAllTasks()).resolves.toEqual([makeTask('fresh-task')]);
expect(readAllTasksUncached).toHaveBeenCalledTimes(2);
});
});