perf(team): cache team data reads safely
This commit is contained in:
parent
9ad32d9978
commit
4385b0c679
31 changed files with 1841 additions and 178 deletions
|
|
@ -22,7 +22,7 @@ import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
|
|||
import type { TeamMember } from '@shared/types';
|
||||
|
||||
export interface TeamTaskAgendaSourceDeps {
|
||||
configReader: TeamConfigReader;
|
||||
configReader: Pick<TeamConfigReader, 'getConfig'>;
|
||||
taskReader: TeamTaskReader;
|
||||
kanbanManager: TeamKanbanManager;
|
||||
membersMetaStore: TeamMembersMetaStore;
|
||||
|
|
|
|||
|
|
@ -124,8 +124,18 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
}): MemberWorkSyncFeatureFacade {
|
||||
const clock = new SystemClockAdapter();
|
||||
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({
|
||||
configReader: deps.configReader,
|
||||
configReader: configReaderForReadOnlySync,
|
||||
taskReader: deps.taskReader,
|
||||
kanbanManager: deps.kanbanManager,
|
||||
membersMetaStore: deps.membersMetaStore,
|
||||
|
|
@ -150,7 +160,7 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
const runtimeTurnSettledTargetResolver =
|
||||
deps.runtimeTurnSettledTargetResolver ??
|
||||
new TeamRuntimeTurnSettledTargetResolver({
|
||||
teamSource: deps.configReader,
|
||||
teamSource: configReaderForReadOnlySync,
|
||||
membersMetaStore: deps.membersMetaStore,
|
||||
});
|
||||
const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths);
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ import {
|
|||
resolveAgentTeamsMcpLaunchSpec,
|
||||
TeamMcpConfigBuilder,
|
||||
} from '@main/services/team/TeamMcpConfigBuilder';
|
||||
import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver';
|
||||
import { killTrackedCliProcesses } from '@main/utils/childProcess';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import {
|
||||
|
|
@ -609,6 +610,8 @@ let shutdownComplete = false;
|
|||
const startupTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const SHUTDOWN_STEP_TIMEOUT_MS = 5_000;
|
||||
const STARTUP_RECOVERY_DELAY_MS = 10_000;
|
||||
const STARTUP_RECOVERY_CONCURRENCY = 1;
|
||||
|
||||
function isShutdownStarted(): boolean {
|
||||
return shutdownComplete || shutdownPromise !== null;
|
||||
|
|
@ -626,6 +629,23 @@ function scheduleStartupTask(action: () => void, delayMs: number): void {
|
|||
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 {
|
||||
for (const timer of startupTimers) {
|
||||
clearTimeout(timer);
|
||||
|
|
@ -808,9 +828,18 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
const detail = typeof row.detail === 'string' ? row.detail : '';
|
||||
memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent);
|
||||
|
||||
if (row.type === 'config' && detail === 'config.json') {
|
||||
TeamConfigReader.invalidateTeam(teamName);
|
||||
getTeamDataWorkerClient().invalidateTeamConfig(teamName);
|
||||
if (row.type === 'config') {
|
||||
if (detail === 'config.json') {
|
||||
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 (
|
||||
|
|
@ -818,6 +847,9 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
(row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config')
|
||||
) {
|
||||
teamDataService.invalidateMessageFeed(teamName);
|
||||
if (row.type === 'inbox' || row.type === 'lead-message') {
|
||||
getTeamDataWorkerClient().invalidateTeamMessageFeed(teamName);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Inbox change events: relay to lead + native OS notifications ---
|
||||
|
|
@ -1060,7 +1092,12 @@ async function initializeServices(): Promise<void> {
|
|||
ptyTerminalService = new PtyTerminalService();
|
||||
const teamMemberLogsFinder = new 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) => {
|
||||
teamTranscriptSourceLocator.invalidateTeam(teamName);
|
||||
});
|
||||
|
|
@ -1203,15 +1240,26 @@ async function initializeServices(): Promise<void> {
|
|||
});
|
||||
|
||||
const forwardTeamChange = (event: TeamChangeEvent): void => {
|
||||
if (event.type === 'config' && event.detail === 'config.json') {
|
||||
TeamConfigReader.invalidateTeam(event.teamName);
|
||||
getTeamDataWorkerClient().invalidateTeamConfig(event.teamName);
|
||||
if (event.type === 'config') {
|
||||
if (event.detail === 'config.json') {
|
||||
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 (
|
||||
teamDataService &&
|
||||
(event.type === 'inbox' || event.type === 'lead-message' || event.type === 'config')
|
||||
) {
|
||||
teamDataService.invalidateMessageFeed(event.teamName);
|
||||
if (event.type === 'inbox' || event.type === 'lead-message') {
|
||||
getTeamDataWorkerClient().invalidateTeamMessageFeed(event.teamName);
|
||||
}
|
||||
}
|
||||
safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
|
||||
httpServer?.broadcast('team-change', event);
|
||||
|
|
@ -1235,18 +1283,25 @@ async function initializeServices(): Promise<void> {
|
|||
teamLogSourceTracker.onLogSourceChange((teamName) => {
|
||||
teammateToolTracker?.handleLogSourceChange(teamName);
|
||||
});
|
||||
void teamDataService
|
||||
.listTeams()
|
||||
.then(async (teams) => {
|
||||
await Promise.all(
|
||||
teams.map((team) =>
|
||||
teamProvisioningService.scanOpenCodePromptDeliveryWatchdog(team.teamName)
|
||||
)
|
||||
scheduleStartupTask(() => {
|
||||
void teamDataService
|
||||
.listTeams()
|
||||
.then(async (teams) => {
|
||||
const activeTeamNames = teams
|
||||
.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)}`)
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) =>
|
||||
logger.warn(`[Init] OpenCode prompt delivery watchdog recovery failed: ${String(error)}`)
|
||||
);
|
||||
}, STARTUP_RECOVERY_DELAY_MS);
|
||||
teamTaskStallMonitor.start();
|
||||
|
||||
// Allow SchedulerService to push schedule events to renderer
|
||||
|
|
@ -1301,16 +1356,25 @@ async function initializeServices(): Promise<void> {
|
|||
? memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment(input)
|
||||
: Promise.resolve(null)
|
||||
);
|
||||
void teamDataService
|
||||
.listTeams()
|
||||
.then(async (teams) => {
|
||||
const activeTeamNames = teams.filter((team) => !team.deletedAt).map((team) => team.teamName);
|
||||
await memberWorkSyncFeature?.replayPendingReports(activeTeamNames);
|
||||
await memberWorkSyncFeature?.enqueueStartupScan(activeTeamNames);
|
||||
})
|
||||
.catch((error: unknown) =>
|
||||
logger.warn(`[Init] Member work sync startup scan failed: ${String(error)}`)
|
||||
);
|
||||
scheduleStartupTask(() => {
|
||||
void teamDataService
|
||||
.listTeams()
|
||||
.then(async (teams) => {
|
||||
const lifecycleActiveTeamNames = teams
|
||||
.filter(
|
||||
(team) =>
|
||||
!team.deletedAt &&
|
||||
(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({
|
||||
logger: createLogger('Feature:CodexAccount'),
|
||||
configManager,
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser';
|
|||
|
||||
const logger = createLogger('IPC:teams');
|
||||
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.
|
||||
|
|
@ -916,35 +917,53 @@ async function handleGetData(
|
|||
const tn = validated.value!;
|
||||
const startedAt = Date.now();
|
||||
let data: TeamViewSnapshot;
|
||||
let dataSource: 'worker' | 'main-fallback' | 'main-unavailable' = 'main-unavailable';
|
||||
let workerAvailable = false;
|
||||
setCurrentMainOp('team:getData');
|
||||
try {
|
||||
// Prefer worker thread to keep main event loop responsive
|
||||
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 {
|
||||
data = await worker.getTeamData(tn);
|
||||
dataSource = 'worker';
|
||||
} catch (workerErr) {
|
||||
logger.warn(
|
||||
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
|
||||
);
|
||||
noteHeavyTeamDataWorkerFallback('teams:getData');
|
||||
data = await getTeamDataService().getTeamData(tn);
|
||||
dataSource = 'main-fallback';
|
||||
}
|
||||
} else {
|
||||
noteHeavyTeamDataWorkerFallback('teams:getData');
|
||||
data = await getTeamDataService().getTeamData(tn);
|
||||
dataSource = 'main-unavailable';
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
message === `Team not found: ${tn}` &&
|
||||
getTeamProvisioningService().hasProvisioningRun(tn)
|
||||
getTeamProvisioningService().hasProvisioningRun?.(tn) === true
|
||||
) {
|
||||
return { success: false, error: 'TEAM_PROVISIONING' };
|
||||
}
|
||||
// Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate)
|
||||
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) {
|
||||
return { success: false, error: 'TEAM_DRAFT' };
|
||||
}
|
||||
|
|
@ -957,7 +976,9 @@ async function handleGetData(
|
|||
const getDataMs = Date.now() - startedAt;
|
||||
|
||||
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();
|
||||
if (data.processes.some((process) => !process.stoppedAt)) {
|
||||
|
|
@ -1015,6 +1036,33 @@ async function handleGetData(
|
|||
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(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
|
|
@ -1116,6 +1164,7 @@ async function handleDeleteTeam(
|
|||
getAutoResumeService().cancelPendingAutoResume(validated.value!);
|
||||
await getTeamProvisioningService().stopTeam(validated.value!);
|
||||
await getTeamDataService().deleteTeam(validated.value!);
|
||||
getTeamDataWorkerClient().invalidateTeamConfig(validated.value!);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1127,7 +1176,10 @@ async function handleRestoreTeam(
|
|||
if (!validated.valid) {
|
||||
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(
|
||||
|
|
@ -1141,6 +1193,7 @@ async function handlePermanentlyDeleteTeam(
|
|||
return wrapTeamHandler('permanentlyDeleteTeam', async () => {
|
||||
getAutoResumeService().cancelPendingAutoResume(validated.value!);
|
||||
await getTeamDataService().permanentlyDeleteTeam(validated.value!);
|
||||
getTeamDataWorkerClient().invalidateTeamConfig(validated.value!);
|
||||
// Clean up app-owned data (attachments, task-attachments) that lives outside ~/.claude/
|
||||
const appData = getAppDataPath();
|
||||
await fs.promises
|
||||
|
|
@ -1208,6 +1261,7 @@ async function handleUpdateConfig(
|
|||
}
|
||||
}
|
||||
|
||||
getTeamDataWorkerClient().invalidateTeamConfig(tn);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
|
@ -2352,35 +2406,47 @@ async function handleGetMessagesPage(
|
|||
|
||||
return wrapTeamHandler('getMessagesPage', async () => {
|
||||
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 =
|
||||
cursor == null ? getTeamProvisioningService().getLiveLeadProcessMessages(vTeam.value!) : [];
|
||||
cursor == null ? getTeamProvisioningService().getLiveLeadProcessMessages(teamName) : [];
|
||||
|
||||
if (liveMessages.length > 0) {
|
||||
page = await getTeamDataService().getMessagesPage(vTeam.value!, {
|
||||
page = await getTeamDataService().getMessagesPage(teamName, {
|
||||
cursor,
|
||||
limit,
|
||||
liveMessages,
|
||||
});
|
||||
scanTeamMessageNotifications(
|
||||
page.messages,
|
||||
vTeam.value!,
|
||||
notificationContext.displayName,
|
||||
notificationContext.projectPath
|
||||
);
|
||||
scanNotifications(page);
|
||||
return page;
|
||||
}
|
||||
|
||||
const worker = getTeamDataWorkerClient();
|
||||
if (worker.isAvailable()) {
|
||||
try {
|
||||
page = await worker.getMessagesPage(vTeam.value!, { cursor, limit });
|
||||
scanTeamMessageNotifications(
|
||||
page.messages,
|
||||
vTeam.value!,
|
||||
notificationContext.displayName,
|
||||
notificationContext.projectPath
|
||||
);
|
||||
page = await worker.getMessagesPage(teamName, { cursor, limit });
|
||||
scanNotifications(page);
|
||||
return page;
|
||||
} catch (workerErr) {
|
||||
logger.warn(
|
||||
|
|
@ -2391,13 +2457,8 @@ async function handleGetMessagesPage(
|
|||
}
|
||||
}
|
||||
noteHeavyTeamDataWorkerFallback('teams:getMessagesPage');
|
||||
page = await getTeamDataService().getMessagesPage(vTeam.value!, { cursor, limit });
|
||||
scanTeamMessageNotifications(
|
||||
page.messages,
|
||||
vTeam.value!,
|
||||
notificationContext.displayName,
|
||||
notificationContext.projectPath
|
||||
);
|
||||
page = await getTeamDataService().getMessagesPage(teamName, { cursor, limit });
|
||||
scanNotifications(page);
|
||||
return page;
|
||||
});
|
||||
}
|
||||
|
|
@ -3307,8 +3368,8 @@ async function handleCreateConfig(
|
|||
});
|
||||
}
|
||||
|
||||
return wrapTeamHandler('createConfig', () =>
|
||||
getTeamDataService().createTeamConfig({
|
||||
return wrapTeamHandler('createConfig', async () => {
|
||||
await getTeamDataService().createTeamConfig({
|
||||
teamName,
|
||||
displayName: payload.displayName?.trim() || undefined,
|
||||
description: payload.description?.trim() || undefined,
|
||||
|
|
@ -3332,8 +3393,9 @@ async function handleCreateConfig(
|
|||
typeof payload.extraCliArgs === 'string' && payload.extraCliArgs.trim()
|
||||
? payload.extraCliArgs.trim()
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
getTeamDataWorkerClient().invalidateTeamConfig(teamName);
|
||||
});
|
||||
}
|
||||
|
||||
function getTeamMemberLogsFinder(): TeamMemberLogsFinder {
|
||||
|
|
|
|||
|
|
@ -1008,7 +1008,8 @@ export class FileWatcher extends EventEmitter {
|
|||
if (
|
||||
relative === 'config.json' ||
|
||||
relative === 'kanban-state.json' ||
|
||||
relative === 'team.meta.json'
|
||||
relative === 'team.meta.json' ||
|
||||
relative === 'members.meta.json'
|
||||
) {
|
||||
const event: TeamChangeEvent = {
|
||||
type: 'config',
|
||||
|
|
|
|||
|
|
@ -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 { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
|
||||
import { buildActionModeAgentBlock } from './actionModeInstructions';
|
||||
import { CascadeGuard } from './CascadeGuard';
|
||||
|
|
@ -117,8 +116,11 @@ export class CrossTeamService {
|
|||
throw new Error(`Target team not found: ${toTeam}`);
|
||||
}
|
||||
|
||||
// 2. Resolve lead
|
||||
const leadName = (await this.dataService.getLeadMemberName(toTeam)) ?? 'team-lead';
|
||||
// 2. Resolve lead. Reuse the verified target config before falling back to meta storage.
|
||||
const leadName =
|
||||
targetConfig.members?.find((m) => isLeadMember(m))?.name?.trim() ||
|
||||
(await this.dataService.getLeadMemberName(toTeam)) ||
|
||||
'team-lead';
|
||||
|
||||
// 3. Format
|
||||
const from = `${fromTeam}.${fromMember}`;
|
||||
|
|
@ -203,39 +205,34 @@ export class CrossTeamService {
|
|||
}
|
||||
|
||||
async listAvailableTargets(excludeTeam?: string): Promise<CrossTeamTarget[]> {
|
||||
const teamsDir = getTeamsBasePath();
|
||||
let entries: string[];
|
||||
let teams: Awaited<ReturnType<TeamDataService['listTeams']>>;
|
||||
try {
|
||||
entries = await fs.promises.readdir(teamsDir);
|
||||
teams = await this.dataService.listTeams();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const targets: CrossTeamTarget[] = [];
|
||||
for (const entry of entries) {
|
||||
if (excludeTeam && entry === excludeTeam) continue;
|
||||
if (!TEAM_NAME_PATTERN.test(entry)) continue;
|
||||
|
||||
let config: TeamConfig | null;
|
||||
try {
|
||||
config = await this.configReader.getConfig(entry);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!config || config.deletedAt) continue;
|
||||
|
||||
const lead = config.members?.find((m) => isLeadMember(m));
|
||||
|
||||
targets.push({
|
||||
teamName: entry,
|
||||
displayName: config.name || entry,
|
||||
description: config.description,
|
||||
color: config.color,
|
||||
leadName: lead?.name,
|
||||
leadColor: lead?.color,
|
||||
isOnline: this.provisioning?.isTeamAlive(entry) ?? false,
|
||||
const targets: CrossTeamTarget[] = teams
|
||||
.filter((team) => {
|
||||
if (excludeTeam && team.teamName === excludeTeam) return false;
|
||||
if (!TEAM_NAME_PATTERN.test(team.teamName)) return false;
|
||||
return !team.deletedAt && !team.pendingCreate;
|
||||
})
|
||||
.map((team) => {
|
||||
const summaryLead =
|
||||
team.leadName || team.leadColor
|
||||
? { name: team.leadName, color: team.leadColor }
|
||||
: team.members?.find((member) => isLeadMember(member));
|
||||
return {
|
||||
teamName: team.teamName,
|
||||
displayName: team.displayName || team.teamName,
|
||||
description: team.description,
|
||||
color: team.color,
|
||||
...(summaryLead?.name ? { leadName: summaryLead.name } : {}),
|
||||
...(summaryLead?.color ? { leadColor: summaryLead.color } : {}),
|
||||
isOnline: this.provisioning?.isTeamAlive(team.teamName) ?? false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return targets.sort((a, b) => {
|
||||
if (a.isOnline && !b.isOnline) return -1;
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ const PER_TEAM_READ_TIMEOUT_MS = 5_000;
|
|||
const GET_CONFIG_SLOW_READ_WARN_MS = 500;
|
||||
const CONFIG_SNAPSHOT_RECENT_STAT_FAILURE_FALLBACK_MS = 5_000;
|
||||
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_PROJECT_PATH_HISTORY_IN_SUMMARY = 200;
|
||||
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
|
||||
|
|
@ -71,13 +72,32 @@ interface CachedTeamConfig {
|
|||
fullVerifiedAt: number;
|
||||
}
|
||||
|
||||
type TeamConfigReadMode = 'verified' | 'snapshot';
|
||||
|
||||
interface ConfigReadTiming {
|
||||
teamName: string;
|
||||
mode: TeamConfigReadMode;
|
||||
configPath: string;
|
||||
size: number | null;
|
||||
statMs: number | null;
|
||||
readMs: number | null;
|
||||
parseMs: number | null;
|
||||
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 {
|
||||
|
|
@ -197,6 +217,48 @@ function cloneConfig(config: TeamConfig): TeamConfig {
|
|||
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 {
|
||||
private static readonly configCacheByPath = new Map<string, CachedTeamConfig>();
|
||||
private static readonly configReadInFlightByPath = new Map<string, Promise<TeamConfig | null>>();
|
||||
|
|
@ -205,12 +267,18 @@ export class TeamConfigReader {
|
|||
Promise<InternalTeamConfigFingerprint | null>
|
||||
>();
|
||||
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 {
|
||||
TeamConfigReader.configCacheByPath.clear();
|
||||
TeamConfigReader.configReadInFlightByPath.clear();
|
||||
TeamConfigReader.configStatInFlightByPath.clear();
|
||||
TeamConfigReader.configGenerationByPath.clear();
|
||||
TeamConfigReader.listTeamsCacheByBasePath.clear();
|
||||
TeamConfigReader.listTeamsInFlightByBasePath.clear();
|
||||
TeamConfigReader.listTeamsGeneration = 0;
|
||||
}
|
||||
|
||||
static invalidateTeam(teamName: string): void {
|
||||
|
|
@ -223,6 +291,17 @@ export class TeamConfigReader {
|
|||
TeamConfigReader.configReadInFlightByPath.delete(configPath);
|
||||
TeamConfigReader.configStatInFlightByPath.delete(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(
|
||||
|
|
@ -245,6 +324,8 @@ export class TeamConfigReader {
|
|||
): Promise<void> {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
const generation = TeamConfigReader.bumpConfigGeneration(configPath);
|
||||
TeamConfigReader.configReadInFlightByPath.delete(configPath);
|
||||
TeamConfigReader.configStatInFlightByPath.delete(configPath);
|
||||
let internalFingerprint: InternalTeamConfigFingerprint | null = null;
|
||||
if (fingerprint) {
|
||||
internalFingerprint = {
|
||||
|
|
@ -259,6 +340,7 @@ export class TeamConfigReader {
|
|||
);
|
||||
}
|
||||
TeamConfigReader.storeConfigCache(configPath, config, internalFingerprint, true, generation);
|
||||
TeamConfigReader.invalidateListTeamsCache();
|
||||
}
|
||||
|
||||
constructor(
|
||||
|
|
@ -267,6 +349,44 @@ export class TeamConfigReader {
|
|||
) {}
|
||||
|
||||
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();
|
||||
if (worker.isAvailable()) {
|
||||
const startedAt = Date.now();
|
||||
|
|
@ -304,7 +424,7 @@ export class TeamConfigReader {
|
|||
}
|
||||
}
|
||||
|
||||
const teamsDir = getTeamsBasePath();
|
||||
const teamsDir = teamsBasePath;
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
|
|
@ -413,6 +533,21 @@ export class TeamConfigReader {
|
|||
const expectedTeammateNames = new Set<string>();
|
||||
const confirmedArtifactNames = new Set<string>();
|
||||
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 name = m.name?.trim();
|
||||
|
|
@ -437,6 +572,7 @@ export class TeamConfigReader {
|
|||
for (const member of metaMembers) {
|
||||
const name = member.name?.trim();
|
||||
if (!name) continue;
|
||||
captureLeadMember(member);
|
||||
// Summary/memberCount should represent teammates (exclude the lead process).
|
||||
if (name === 'user' || isLeadMember(member)) continue;
|
||||
const key = name.toLowerCase();
|
||||
|
|
@ -462,6 +598,7 @@ export class TeamConfigReader {
|
|||
for (const member of config.members) {
|
||||
if (member && typeof member.name === 'string') {
|
||||
const name = member.name.trim();
|
||||
captureLeadMember(member, true);
|
||||
if (name && name !== 'user' && !isLeadMember(member)) {
|
||||
confirmedArtifactNames.add(name);
|
||||
}
|
||||
|
|
@ -537,6 +674,8 @@ export class TeamConfigReader {
|
|||
taskCount: 0,
|
||||
lastActivity: null,
|
||||
...(members.length > 0 ? { members } : {}),
|
||||
...(leadName ? { leadName } : {}),
|
||||
...(leadColor ? { leadColor } : {}),
|
||||
...(color ? { color } : {}),
|
||||
...(projectPath ? { projectPath } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
|
|
@ -578,11 +717,21 @@ export class TeamConfigReader {
|
|||
: teamName;
|
||||
|
||||
let memberCount = 0;
|
||||
let leadName: string | undefined;
|
||||
let leadColor: string | undefined;
|
||||
try {
|
||||
const metaStore = new TeamMembersMetaStore();
|
||||
const members = await metaStore.getMembers(teamName);
|
||||
const members = await this.membersMetaStore.getMembers(teamName);
|
||||
memberCount = members.filter((member) => {
|
||||
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)) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -601,6 +750,8 @@ export class TeamConfigReader {
|
|||
lastActivity:
|
||||
typeof meta.createdAt === 'number' ? new Date(meta.createdAt).toISOString() : null,
|
||||
color: typeof meta.color === 'string' ? meta.color : undefined,
|
||||
...(leadName ? { leadName } : {}),
|
||||
...(leadColor ? { leadColor } : {}),
|
||||
projectPath: typeof meta.cwd === 'string' ? meta.cwd : undefined,
|
||||
pendingCreate: true,
|
||||
};
|
||||
|
|
@ -621,7 +772,14 @@ export class TeamConfigReader {
|
|||
}
|
||||
|
||||
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);
|
||||
|
||||
try {
|
||||
|
|
@ -700,7 +858,8 @@ export class TeamConfigReader {
|
|||
configPath,
|
||||
fingerprint,
|
||||
true,
|
||||
generation
|
||||
generation,
|
||||
'snapshot'
|
||||
);
|
||||
TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise);
|
||||
try {
|
||||
|
|
@ -842,21 +1001,31 @@ export class TeamConfigReader {
|
|||
configPath: string,
|
||||
knownFingerprint: InternalTeamConfigFingerprint | null = null,
|
||||
updateCache = false,
|
||||
cacheGeneration?: number
|
||||
cacheGeneration?: number,
|
||||
mode: TeamConfigReadMode = 'verified'
|
||||
): Promise<TeamConfig | null> {
|
||||
const startedAt = performance.now();
|
||||
const caller = captureConfigReadCaller();
|
||||
let size: number | null = null;
|
||||
let statMs: number | null = null;
|
||||
let readMs: number | null = null;
|
||||
let parseMs: number | null = null;
|
||||
let fingerprintHighResolution: boolean | null = knownFingerprint?.highResolution ?? null;
|
||||
|
||||
const buildTiming = (): ConfigReadTiming => ({
|
||||
teamName,
|
||||
mode,
|
||||
configPath,
|
||||
size,
|
||||
statMs,
|
||||
readMs,
|
||||
parseMs,
|
||||
totalMs: Math.round(performance.now() - startedAt),
|
||||
likelyCause: classifyConfigReadTiming({ statMs, readMs, parseMs }),
|
||||
fingerprintHighResolution,
|
||||
cacheGeneration: cacheGeneration ?? null,
|
||||
currentGeneration: TeamConfigReader.getConfigGeneration(configPath),
|
||||
caller,
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -865,6 +1034,7 @@ export class TeamConfigReader {
|
|||
knownFingerprint ?? (await TeamConfigReader.getConfigFingerprint(configPath));
|
||||
statMs = Math.round(performance.now() - statStartedAt);
|
||||
size = fingerprint?.numericSize ?? null;
|
||||
fingerprintHighResolution = fingerprint?.highResolution ?? null;
|
||||
|
||||
// Safety: refuse special files and huge/binary configs
|
||||
if (!fingerprint?.isFile) {
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ const PROCESS_HEALTH_INTERVAL_MS = 2_000;
|
|||
const TASK_MAP_YIELD_EVERY = 250;
|
||||
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
|
||||
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 =
|
||||
'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);
|
||||
}
|
||||
|
||||
private invalidateGlobalTaskProjectionCache(): void {
|
||||
TeamTaskReader.invalidateAllTasksCache();
|
||||
}
|
||||
|
||||
private getController(teamName: string): AgentTeamsController {
|
||||
return this.controllerFactory(teamName);
|
||||
}
|
||||
|
|
@ -514,7 +518,7 @@ export class TeamDataService {
|
|||
request.catch(() => {
|
||||
/* 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`
|
||||
);
|
||||
return new Map();
|
||||
|
|
@ -865,7 +869,7 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
async getTaskChangePresence(teamName: string): Promise<Record<string, TaskChangePresenceState>> {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await this.readSnapshotConfig(teamName);
|
||||
if (!config) {
|
||||
throw new Error(`Team not found: ${teamName}`);
|
||||
}
|
||||
|
|
@ -1121,6 +1125,7 @@ export class TeamDataService {
|
|||
|
||||
const tasksDir = path.join(getTasksBasePath(), teamName);
|
||||
await fs.promises.rm(tasksDir, { recursive: true, force: true });
|
||||
TeamTaskReader.invalidateAllTasksCache();
|
||||
}
|
||||
|
||||
async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
|
||||
|
|
@ -1915,6 +1920,7 @@ export class TeamDataService {
|
|||
...(request.promptTaskRefs?.length ? { promptTaskRefs: request.promptTaskRefs } : {}),
|
||||
...(shouldStart ? { startImmediately: true } : {}),
|
||||
}) as TeamTask;
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
|
||||
// Controller's maybeNotifyAssignedOwner skips the lead (owner === lead).
|
||||
// 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.invalidateGlobalTaskProjectionCache();
|
||||
|
||||
if (task.owner) {
|
||||
try {
|
||||
|
|
@ -1995,6 +2002,7 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
this.getController(teamName).tasks.startTask(taskId, 'user');
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
|
||||
if (task.owner) {
|
||||
await this.sendUserTaskStartNotification(teamName, task);
|
||||
|
|
@ -2050,6 +2058,7 @@ export class TeamDataService {
|
|||
actor?: string
|
||||
): Promise<void> {
|
||||
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> {
|
||||
this.getController(teamName).tasks.softDeleteTask(taskId, 'user');
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
}
|
||||
|
||||
async restoreTask(teamName: string, taskId: string): Promise<void> {
|
||||
this.getController(teamName).tasks.restoreTask(taskId, 'user');
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
}
|
||||
|
||||
async getDeletedTasks(teamName: string): Promise<TeamTask[]> {
|
||||
|
|
@ -2121,6 +2132,7 @@ export class TeamDataService {
|
|||
|
||||
async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise<void> {
|
||||
this.getController(teamName).tasks.setTaskOwner(taskId, owner);
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
}
|
||||
|
||||
async updateTaskFields(
|
||||
|
|
@ -2129,6 +2141,7 @@ export class TeamDataService {
|
|||
fields: { subject?: string; description?: string }
|
||||
): Promise<void> {
|
||||
this.getController(teamName).tasks.updateTaskFields(taskId, fields);
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
}
|
||||
|
||||
async addTaskAttachment(
|
||||
|
|
@ -2140,6 +2153,7 @@ export class TeamDataService {
|
|||
taskId,
|
||||
meta as unknown as Record<string, unknown>
|
||||
);
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
}
|
||||
|
||||
async removeTaskAttachment(
|
||||
|
|
@ -2148,6 +2162,7 @@ export class TeamDataService {
|
|||
attachmentId: string
|
||||
): Promise<void> {
|
||||
this.getController(teamName).tasks.removeTaskAttachment(taskId, attachmentId);
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
}
|
||||
|
||||
async setTaskNeedsClarification(
|
||||
|
|
@ -2156,6 +2171,7 @@ export class TeamDataService {
|
|||
value: 'lead' | 'user' | null
|
||||
): Promise<void> {
|
||||
this.getController(teamName).tasks.setNeedsClarification(taskId, value);
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
}
|
||||
|
||||
async addTaskRelationship(
|
||||
|
|
@ -2169,6 +2185,7 @@ export class TeamDataService {
|
|||
targetId,
|
||||
type === 'blockedBy' ? 'blocked-by' : type
|
||||
);
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
}
|
||||
|
||||
async removeTaskRelationship(
|
||||
|
|
@ -2182,6 +2199,7 @@ export class TeamDataService {
|
|||
targetId,
|
||||
type === 'blockedBy' ? 'blocked-by' : type
|
||||
);
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
}
|
||||
|
||||
async addTaskComment(
|
||||
|
|
@ -2198,6 +2216,7 @@ export class TeamDataService {
|
|||
attachments,
|
||||
taskRefs,
|
||||
}) as { task?: TeamTask; comment?: TaskComment };
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
const comment =
|
||||
addResult.comment ??
|
||||
({
|
||||
|
|
@ -2832,7 +2851,7 @@ export class TeamDataService {
|
|||
|
||||
async getTeamDisplayName(teamName: string): Promise<string> {
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await this.readSnapshotConfig(teamName);
|
||||
const displayName = config?.name?.trim();
|
||||
return displayName || teamName;
|
||||
} catch {
|
||||
|
|
@ -2845,7 +2864,7 @@ export class TeamDataService {
|
|||
projectPath?: string;
|
||||
}> {
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await this.readSnapshotConfig(teamName);
|
||||
const displayName = config?.name?.trim() || teamName;
|
||||
const projectPath =
|
||||
typeof config?.projectPath === 'string' && config.projectPath.trim().length > 0
|
||||
|
|
@ -2943,6 +2962,7 @@ export class TeamDataService {
|
|||
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, {
|
||||
providerBackendId: request.providerBackendId,
|
||||
});
|
||||
TeamConfigReader.invalidateListTeamsCache();
|
||||
}
|
||||
|
||||
async reconcileTeamArtifacts(
|
||||
|
|
|
|||
|
|
@ -59,16 +59,50 @@ function resolveWorkerPath(): string | null {
|
|||
}
|
||||
|
||||
interface PendingEntry {
|
||||
resolve: (v: unknown) => void;
|
||||
resolve: (v: unknown, diag?: Extract<TeamDataWorkerResponse, { ok: true }>['diag']) => 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 {
|
||||
private worker: Worker | null = null;
|
||||
private readonly workerPath: string | null = resolveWorkerPath();
|
||||
private warnedUnavailable = false;
|
||||
private pending = new Map<string, PendingEntry>();
|
||||
private getTeamDataInFlight = new Map<string, Promise<TeamViewSnapshot>>();
|
||||
private getMessagesPageInFlight = new Map<string, Promise<MessagesPage>>();
|
||||
|
||||
private failWorker(worker: Worker, error: Error): void {
|
||||
if (this.worker !== worker) return;
|
||||
|
|
@ -104,7 +138,7 @@ export class TeamDataWorkerClient {
|
|||
if (!entry) return;
|
||||
this.pending.delete(msg.id);
|
||||
if (msg.ok) {
|
||||
entry.resolve(msg.result);
|
||||
entry.resolve(msg.result, msg.diag);
|
||||
} else {
|
||||
entry.reject(new Error(msg.error));
|
||||
}
|
||||
|
|
@ -132,22 +166,45 @@ export class TeamDataWorkerClient {
|
|||
): Promise<unknown> {
|
||||
const worker = this.ensureWorker();
|
||||
const id = makeId();
|
||||
const startedAt = Date.now();
|
||||
const pendingAtStart = this.pending.size;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
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);
|
||||
worker.terminate().catch(() => undefined);
|
||||
reject(timeoutError);
|
||||
}, WORKER_CALL_TIMEOUT_MS);
|
||||
|
||||
this.pending.set(id, {
|
||||
resolve: (value) => {
|
||||
resolve: (value, diag) => {
|
||||
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);
|
||||
},
|
||||
reject: (error) => {
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
|
@ -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> {
|
||||
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
||||
const existing = this.getTeamDataInFlight.get(teamName);
|
||||
|
|
@ -175,8 +249,23 @@ export class TeamDataWorkerClient {
|
|||
invalidateTeamConfig(teamName: string): void {
|
||||
if (!SAFE_NAME_RE.test(teamName)) return;
|
||||
this.getTeamDataInFlight.delete(teamName);
|
||||
if (!this.worker) return;
|
||||
void this.call('invalidateTeamConfig', { teamName }).catch(() => undefined);
|
||||
this.clearMessagesPageInFlightForTeam(teamName);
|
||||
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(
|
||||
|
|
@ -184,7 +273,26 @@ export class TeamDataWorkerClient {
|
|||
options: { cursor?: string | null; limit: number }
|
||||
): Promise<MessagesPage> {
|
||||
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> {
|
||||
|
|
@ -213,6 +321,7 @@ export class TeamDataWorkerClient {
|
|||
this.worker?.terminate().catch(() => undefined);
|
||||
this.worker = null;
|
||||
this.getTeamDataInFlight.clear();
|
||||
this.getMessagesPageInFlight.clear();
|
||||
for (const [, entry] of this.pending) {
|
||||
entry.reject(new Error('Client disposed'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,27 @@ type WorkerResponse =
|
|||
| { id: string; ok: true; result: unknown; diag?: WorkerDiag }
|
||||
| { 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 {
|
||||
return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`;
|
||||
}
|
||||
|
|
@ -152,6 +173,8 @@ export class TeamFsWorkerClient {
|
|||
): Promise<{ result: unknown; diag?: WorkerDiag }> {
|
||||
const worker = this.ensureWorker();
|
||||
const id = makeId();
|
||||
const startedAt = Date.now();
|
||||
const pendingAtStart = this.pending.size;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
|
|
@ -163,16 +186,37 @@ export class TeamFsWorkerClient {
|
|||
} finally {
|
||||
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})`));
|
||||
}, WORKER_CALL_TIMEOUT_MS);
|
||||
|
||||
this.pending.set(id, {
|
||||
resolve: (value) => {
|
||||
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);
|
||||
},
|
||||
reject: (error) => {
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -148,7 +148,9 @@ export class TeamMemberLogsFinder {
|
|||
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
|
||||
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
|
||||
private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver(
|
||||
configReader
|
||||
{
|
||||
getConfig: (teamName) => configReader.getConfigSnapshot(teamName),
|
||||
}
|
||||
)
|
||||
) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ interface RuntimeAdvisoryLogsFinder {
|
|||
const LOOKBACK_MS = 10 * 60 * 1000;
|
||||
const CACHE_TTL_MS = 30_000;
|
||||
const TAIL_BYTES = 64 * 1024;
|
||||
const BATCH_WARN_MS = 200;
|
||||
const BATCH_WARN_MS = 1_000;
|
||||
const ADVISORY_FETCH_CONCURRENCY = 2;
|
||||
const QUOTA_EXHAUSTED_TOKENS = [
|
||||
'exhausted your capacity',
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@ interface TeamMessageFeedCacheEntry {
|
|||
cachedAt: number;
|
||||
}
|
||||
|
||||
interface InFlightTeamMessageFeed {
|
||||
promise: Promise<TeamNormalizedMessageFeed>;
|
||||
generationAtStart: number;
|
||||
}
|
||||
|
||||
export interface TeamNormalizedMessageFeed {
|
||||
teamName: string;
|
||||
feedRevision: string;
|
||||
|
|
@ -411,11 +416,14 @@ function toFeedRevision(messages: readonly InboxMessage[]): string {
|
|||
export class TeamMessageFeedService {
|
||||
private readonly cacheByTeam = new Map<string, TeamMessageFeedCacheEntry>();
|
||||
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) {}
|
||||
|
||||
invalidate(teamName: string): void {
|
||||
this.dirtyTeams.add(teamName);
|
||||
this.generationByTeam.set(teamName, this.getGeneration(teamName) + 1);
|
||||
}
|
||||
|
||||
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 configMs = Date.now() - configStartedAt;
|
||||
if (!config) {
|
||||
const emptyEntry = { feedRevision: toFeedRevision([]), messages: [], cachedAt: now };
|
||||
this.cacheByTeam.set(teamName, emptyEntry);
|
||||
this.dirtyTeams.delete(teamName);
|
||||
if (this.getGeneration(teamName) === generationAtStart) {
|
||||
this.cacheByTeam.set(teamName, emptyEntry);
|
||||
this.dirtyTeams.delete(teamName);
|
||||
}
|
||||
return { teamName, ...emptyEntry };
|
||||
}
|
||||
|
||||
const sourceStartedAt = Date.now();
|
||||
const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
|
||||
this.deps.getInboxMessages(teamName).catch(() => [] as InboxMessage[]),
|
||||
this.deps.getLeadSessionMessages(teamName, config).catch(() => [] as InboxMessage[]),
|
||||
this.deps.getSentMessages(teamName).catch(() => [] as InboxMessage[]),
|
||||
]);
|
||||
const sourceMs = Date.now() - sourceStartedAt;
|
||||
|
||||
const normalizeStartedAt = Date.now();
|
||||
const syntheticMessages = buildSyntheticOpenCodeBootstrapMessages(config);
|
||||
let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages];
|
||||
messages = dedupeLeadProcessCopies(messages, leadTexts);
|
||||
|
|
@ -461,6 +514,13 @@ export class TeamMessageFeedService {
|
|||
});
|
||||
|
||||
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) {
|
||||
logger.warn(
|
||||
`[${teamName}] Message feed cache expired without dirty invalidation and recovered newer durable messages`
|
||||
|
|
@ -478,8 +538,10 @@ export class TeamMessageFeedService {
|
|||
cachedAt: now,
|
||||
};
|
||||
|
||||
this.cacheByTeam.set(teamName, nextEntry);
|
||||
this.dirtyTeams.delete(teamName);
|
||||
if (this.getGeneration(teamName) === generationAtStart) {
|
||||
this.cacheByTeam.set(teamName, nextEntry);
|
||||
this.dirtyTeams.delete(teamName);
|
||||
}
|
||||
return {
|
||||
teamName,
|
||||
feedRevision: nextEntry.feedRevision,
|
||||
|
|
|
|||
|
|
@ -4599,7 +4599,18 @@ export class TeamProvisioningService {
|
|||
this.inboxReader,
|
||||
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 {
|
||||
|
|
@ -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();
|
||||
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(
|
||||
(member) => member.name?.trim().toLowerCase() === normalizedMemberName
|
||||
);
|
||||
|
|
@ -5491,13 +5501,37 @@ export class TeamProvisioningService {
|
|||
);
|
||||
const configProvider = (configMember as { provider?: unknown } | undefined)?.provider;
|
||||
const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider;
|
||||
const providerId =
|
||||
return (
|
||||
normalizeTeamProviderLike(metaMember?.providerId) ??
|
||||
normalizeTeamProviderLike(metaProvider) ??
|
||||
normalizeTeamProviderLike(configMember?.providerId) ??
|
||||
normalizeTeamProviderLike(configProvider) ??
|
||||
inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model);
|
||||
return providerId === 'opencode';
|
||||
inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model)
|
||||
);
|
||||
}
|
||||
|
||||
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: {
|
||||
|
|
@ -10334,7 +10368,8 @@ export class TeamProvisioningService {
|
|||
|
||||
let configuredMembers: TeamConfig['members'] = [];
|
||||
try {
|
||||
configuredMembers = (await this.configReader.getConfig(teamName))?.members ?? [];
|
||||
const config = await this.readConfigSnapshot(teamName);
|
||||
configuredMembers = config?.members ?? [];
|
||||
} catch {
|
||||
configuredMembers = [];
|
||||
}
|
||||
|
|
@ -10671,6 +10706,7 @@ export class TeamProvisioningService {
|
|||
}
|
||||
parsed.members = members;
|
||||
await atomicWriteAsync(configPath, `${JSON.stringify(parsed, null, 2)}\n`);
|
||||
TeamConfigReader.invalidateTeam(input.teamName);
|
||||
}
|
||||
|
||||
private enqueueDirectRestartPrompt(input: {
|
||||
|
|
@ -14434,6 +14470,7 @@ export class TeamProvisioningService {
|
|||
],
|
||||
};
|
||||
await atomicWriteAsync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
||||
TeamConfigReader.invalidateTeam(request.teamName);
|
||||
}
|
||||
|
||||
private async persistOpenCodeRuntimeAdapterLaunchResult(
|
||||
|
|
@ -15760,14 +15797,18 @@ export class TeamProvisioningService {
|
|||
return { kind: 'ignored', relayed: 0 };
|
||||
}
|
||||
|
||||
const leadName = await this.configReader
|
||||
.getConfig(teamName)
|
||||
.then(
|
||||
(config) => config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null
|
||||
)
|
||||
.catch(() => null);
|
||||
const [config, metaMembers] = await Promise.all([
|
||||
this.readConfigSnapshot(teamName).catch(() => null),
|
||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
||||
]);
|
||||
const leadName = config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null;
|
||||
const isOpenCodeRecipient = this.isOpenCodeRuntimeRecipientFromSources(
|
||||
inboxName,
|
||||
config,
|
||||
metaMembers
|
||||
);
|
||||
if (inboxName.trim().toLowerCase() === leadName?.toLowerCase()) {
|
||||
if (await this.isOpenCodeRuntimeRecipient(teamName, inboxName)) {
|
||||
if (isOpenCodeRecipient) {
|
||||
const diagnostic =
|
||||
'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}`);
|
||||
|
|
@ -15783,7 +15824,7 @@ export class TeamProvisioningService {
|
|||
};
|
||||
}
|
||||
|
||||
if (await this.isOpenCodeRuntimeRecipient(teamName, inboxName)) {
|
||||
if (isOpenCodeRecipient) {
|
||||
const relayOptions: OpenCodeMemberInboxRelayOptions = {
|
||||
source: options.source ?? 'watcher',
|
||||
...(options.onlyMessageId ? { onlyMessageId: options.onlyMessageId } : {}),
|
||||
|
|
@ -17444,7 +17485,7 @@ export class TeamProvisioningService {
|
|||
|
||||
let configuredMembers: TeamConfig['members'] = [];
|
||||
try {
|
||||
configuredMembers = (await this.configReader.getConfig(teamName))?.members ?? [];
|
||||
configuredMembers = (await this.readConfigSnapshot(teamName))?.members ?? [];
|
||||
} catch {
|
||||
configuredMembers = [];
|
||||
}
|
||||
|
|
@ -20169,9 +20210,9 @@ export class TeamProvisioningService {
|
|||
memberName: string,
|
||||
sinceMs: number | null
|
||||
): Promise<BootstrapTranscriptOutcome[]> {
|
||||
let config: Awaited<ReturnType<TeamConfigReader['getConfig']>>;
|
||||
let config: TeamConfig | null;
|
||||
try {
|
||||
config = await this.configReader.getConfig(teamName);
|
||||
config = await this.readConfigSnapshot(teamName);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -20221,7 +20262,7 @@ export class TeamProvisioningService {
|
|||
private async collectBootstrapTranscriptProjectDirs(
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
config: Awaited<ReturnType<TeamConfigReader['getConfig']>>
|
||||
config: TeamConfig | null
|
||||
): Promise<string[]> {
|
||||
const pathCandidates: string[] = [];
|
||||
const pathSeen = new Set<string>();
|
||||
|
|
@ -24435,6 +24476,7 @@ export class TeamProvisioningService {
|
|||
config.projectPathHistory = pathHistory.slice(-500);
|
||||
|
||||
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
||||
TeamConfigReader.invalidateTeam(teamName);
|
||||
logger.info(`[${teamName}] Updated config.projectPath immediately: ${cwd}`);
|
||||
} catch (error) {
|
||||
// Non-fatal: updateConfigPostLaunch will update it later if provisioning succeeds.
|
||||
|
|
@ -24671,6 +24713,7 @@ export class TeamProvisioningService {
|
|||
this.applyEffectiveLaunchStateToConfig(teamName, config, launchState);
|
||||
|
||||
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
||||
TeamConfigReader.invalidateTeam(teamName);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[${teamName}] Failed to update config post-launch: ${
|
||||
|
|
@ -24721,6 +24764,7 @@ export class TeamProvisioningService {
|
|||
if (removedFromConfig.length > 0) {
|
||||
parsed.members = nextMembers;
|
||||
await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2));
|
||||
TeamConfigReader.invalidateTeam(teamName);
|
||||
logger.warn(
|
||||
`[${teamName}] Removed CLI auto-suffixed members from config.json: ${removedFromConfig.join(', ')}`
|
||||
);
|
||||
|
|
@ -24977,6 +25021,7 @@ export class TeamProvisioningService {
|
|||
config.members = leadMembers;
|
||||
try {
|
||||
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
||||
TeamConfigReader.invalidateTeam(teamName);
|
||||
logger.info(
|
||||
`[${teamName}] Normalized config.json for launch: kept ${leadMembers.length} lead member(s)`
|
||||
);
|
||||
|
|
@ -25008,6 +25053,7 @@ export class TeamProvisioningService {
|
|||
return;
|
||||
}
|
||||
await atomicWriteAsync(configPath, backupRaw);
|
||||
TeamConfigReader.invalidateTeam(teamName);
|
||||
logger.info(`[${teamName}] Restored config.json from prelaunch backup after launch failure`);
|
||||
} catch {
|
||||
logger.debug(`[${teamName}] No prelaunch backup to restore (or read failed)`);
|
||||
|
|
|
|||
|
|
@ -21,13 +21,18 @@ import type {
|
|||
|
||||
const logger = createLogger('Service:TeamTaskReader');
|
||||
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 {
|
||||
value: (TeamTask & { teamName: string })[];
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface InFlightAllTasks {
|
||||
promise: Promise<(TeamTask & { teamName: string })[]>;
|
||||
generationAtStart: number;
|
||||
}
|
||||
|
||||
function cloneTasks<T>(tasks: T[]): T[] {
|
||||
return structuredClone(tasks);
|
||||
}
|
||||
|
|
@ -74,7 +79,13 @@ function normalizeTaskRefs(value: unknown): TaskRef[] | undefined {
|
|||
|
||||
export class TeamTaskReader {
|
||||
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
|
||||
|
|
@ -446,26 +457,55 @@ export class TeamTaskReader {
|
|||
}
|
||||
|
||||
async getAllTasks(): Promise<(TeamTask & { teamName: string })[]> {
|
||||
const startedAt = Date.now();
|
||||
const cached = TeamTaskReader.allTasksCache;
|
||||
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) {
|
||||
return cloneTasks(await TeamTaskReader.allTasksInFlight);
|
||||
if (
|
||||
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();
|
||||
TeamTaskReader.allTasksInFlight = request;
|
||||
const generationAtStart = TeamTaskReader.allTasksGeneration;
|
||||
TeamTaskReader.allTasksInFlight = {
|
||||
promise: request,
|
||||
generationAtStart,
|
||||
};
|
||||
try {
|
||||
const tasks = await request;
|
||||
TeamTaskReader.allTasksCache = {
|
||||
value: cloneTasks(tasks),
|
||||
expiresAt: Date.now() + ALL_TASKS_CACHE_TTL_MS,
|
||||
};
|
||||
return cloneTasks(tasks);
|
||||
if (TeamTaskReader.allTasksGeneration === generationAtStart) {
|
||||
TeamTaskReader.allTasksCache = {
|
||||
value: cloneTasks(tasks),
|
||||
expiresAt: Date.now() + ALL_TASKS_CACHE_TTL_MS,
|
||||
};
|
||||
}
|
||||
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 {
|
||||
if (TeamTaskReader.allTasksInFlight === request) {
|
||||
if (TeamTaskReader.allTasksInFlight?.promise === request) {
|
||||
TeamTaskReader.allTasksInFlight = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,17 @@ export interface InvalidateTeamConfigPayload {
|
|||
teamName: string;
|
||||
}
|
||||
|
||||
export interface InvalidateTeamMessageFeedPayload {
|
||||
teamName: string;
|
||||
}
|
||||
|
||||
export interface TeamDataWorkerDiag {
|
||||
op: TeamDataWorkerRequest['op'];
|
||||
teamName?: string;
|
||||
taskId?: string;
|
||||
totalMs: number;
|
||||
}
|
||||
|
||||
// ── Request / Response ──
|
||||
|
||||
export type TeamDataWorkerRequest =
|
||||
|
|
@ -49,12 +60,14 @@ export type TeamDataWorkerRequest =
|
|||
| { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload }
|
||||
| { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload }
|
||||
| { 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 =
|
||||
| {
|
||||
id: string;
|
||||
ok: true;
|
||||
result: TeamViewSnapshot | MessagesPage | TeamMemberActivityMeta | MemberLogSummary[] | null;
|
||||
diag?: TeamDataWorkerDiag;
|
||||
}
|
||||
| { id: string; ok: false; error: string };
|
||||
|
|
|
|||
|
|
@ -36,11 +36,18 @@ function respond(msg: TeamDataWorkerResponse): void {
|
|||
}
|
||||
|
||||
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 {
|
||||
switch (msg.op) {
|
||||
case 'getTeamData': {
|
||||
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;
|
||||
}
|
||||
case 'getMessagesPage': {
|
||||
|
|
@ -48,17 +55,23 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
|
|||
msg.payload.teamName,
|
||||
msg.payload.options
|
||||
);
|
||||
respond({ id: msg.id, ok: true, result });
|
||||
respond({ id: msg.id, ok: true, result, diag: buildDiag() });
|
||||
break;
|
||||
}
|
||||
case 'getMemberActivityMeta': {
|
||||
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;
|
||||
}
|
||||
case 'invalidateTeamConfig': {
|
||||
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;
|
||||
}
|
||||
case 'findLogsForTask': {
|
||||
|
|
@ -95,7 +108,7 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
|
|||
logsInFlight.set(cacheKey, promise);
|
||||
}
|
||||
const result = await promise;
|
||||
respond({ id: msg.id, ok: true, result });
|
||||
respond({ id: msg.id, ok: true, result, diag: buildDiag() });
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
|
|
|
|||
|
|
@ -384,13 +384,14 @@ async function readLaunchState(
|
|||
*/
|
||||
async function readDraftTeamMeta(
|
||||
teamsDir: string,
|
||||
teamName: string
|
||||
teamName: string,
|
||||
options: { maxConfigReadMs: number; maxMembersMetaBytes: number }
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const metaPath = path.join(teamsDir, teamName, 'team.meta.json');
|
||||
try {
|
||||
const stat = await fs.promises.stat(metaPath);
|
||||
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>;
|
||||
if (meta?.version !== 1 || typeof meta?.cwd !== 'string') return null;
|
||||
|
||||
|
|
@ -401,14 +402,29 @@ async function readDraftTeamMeta(
|
|||
|
||||
// Read members.meta.json for member count
|
||||
let memberCount = 0;
|
||||
let leadName: string | undefined;
|
||||
let leadColor: string | undefined;
|
||||
try {
|
||||
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[] };
|
||||
if (Array.isArray(membersData?.members)) {
|
||||
memberCount = membersData.members.filter((member) => {
|
||||
if (!isRawMember(member)) return false;
|
||||
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;
|
||||
return !member.removedAt;
|
||||
}).length;
|
||||
|
|
@ -426,6 +442,8 @@ async function readDraftTeamMeta(
|
|||
lastActivity:
|
||||
typeof meta.createdAt === 'number' ? new Date(meta.createdAt).toISOString() : null,
|
||||
color: typeof meta.color === 'string' ? meta.color : undefined,
|
||||
...(leadName ? { leadName } : {}),
|
||||
...(leadColor ? { leadColor } : {}),
|
||||
projectPath: typeof meta.cwd === 'string' ? meta.cwd : undefined,
|
||||
pendingCreate: true,
|
||||
};
|
||||
|
|
@ -477,12 +495,12 @@ async function listTeams(
|
|||
stat = await fs.promises.stat(configPath);
|
||||
} catch {
|
||||
// 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;
|
||||
return skip('config_stat_failed');
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
const draft = await readDraftTeamMeta(payload.teamsDir, teamName);
|
||||
const draft = await readDraftTeamMeta(payload.teamsDir, teamName, payload);
|
||||
if (draft) return draft;
|
||||
return skip('config_not_file');
|
||||
}
|
||||
|
|
@ -557,6 +575,21 @@ async function listTeams(
|
|||
removedAt?: unknown;
|
||||
}[] = [];
|
||||
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 {
|
||||
const teamMetaPath = path.join(payload.teamsDir, teamName, 'team.meta.json');
|
||||
|
|
@ -595,6 +628,7 @@ async function listTeams(
|
|||
: undefined;
|
||||
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
||||
if (!name) continue;
|
||||
captureLeadMember(member);
|
||||
if (isLeadMember(member)) continue;
|
||||
const key = name.toLowerCase();
|
||||
if (member.removedAt) {
|
||||
|
|
@ -623,6 +657,7 @@ async function listTeams(
|
|||
for (const member of config.members as unknown[]) {
|
||||
if (isRawMember(member)) {
|
||||
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
||||
captureLeadMember(member, true);
|
||||
if (name && name !== 'user' && !isLeadMember(member)) {
|
||||
confirmedArtifactNames.add(name);
|
||||
}
|
||||
|
|
@ -691,6 +726,8 @@ async function listTeams(
|
|||
taskCount: 0,
|
||||
lastActivity: null,
|
||||
...(coloredMembers.length > 0 ? { members: coloredMembers } : {}),
|
||||
...(leadName ? { leadName } : {}),
|
||||
...(leadColor ? { leadColor } : {}),
|
||||
...(color ? { color } : {}),
|
||||
...(projectPath ? { projectPath } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ export interface TeamSummary {
|
|||
color?: string;
|
||||
memberCount: number;
|
||||
members?: TeamSummaryMember[];
|
||||
leadName?: string;
|
||||
leadColor?: string;
|
||||
taskCount: number;
|
||||
lastActivity: string | null;
|
||||
projectPath?: string;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
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 () => {
|
||||
const root = makeTempRoot();
|
||||
const feature = createMemberWorkSyncFeature({
|
||||
|
|
|
|||
|
|
@ -50,8 +50,29 @@ const { mockTeamDataWorkerClient } = vi.hoisted(() => ({
|
|||
getMemberActivityMeta: vi.fn(),
|
||||
findLogsForTask: 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', () => ({
|
||||
NotificationManager: {
|
||||
getInstance: vi.fn().mockReturnValue({
|
||||
|
|
@ -188,6 +209,8 @@ describe('ipc teams handlers', () => {
|
|||
projectPath: '/tmp/project',
|
||||
})),
|
||||
deleteTeam: vi.fn(async () => undefined),
|
||||
restoreTeam: vi.fn(async () => undefined),
|
||||
permanentlyDeleteTeam: vi.fn(async () => undefined),
|
||||
getLeadMemberName: vi.fn(async () => 'team-lead'),
|
||||
getTeamDisplayName: vi.fn(async () => 'My Team'),
|
||||
updateConfig: vi.fn(async () => ({ name: 'My Team' })),
|
||||
|
|
@ -315,6 +338,7 @@ describe('ipc teams handlers', () => {
|
|||
mockTeamDataWorkerClient.getMemberActivityMeta.mockReset();
|
||||
mockTeamDataWorkerClient.findLogsForTask.mockReset();
|
||||
mockTeamDataWorkerClient.invalidateTeamConfig.mockReset();
|
||||
mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset();
|
||||
initializeTeamHandlers(
|
||||
service as never,
|
||||
provisioningService as never,
|
||||
|
|
@ -1089,6 +1113,137 @@ describe('ipc teams handlers', () => {
|
|||
(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 () => {
|
||||
vi.useFakeTimers();
|
||||
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.data.feedRevision).toBe('rev-worker');
|
||||
await flushMicrotasks();
|
||||
expect(mockAddTeamNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamEventType: 'rate_limit',
|
||||
|
|
@ -1233,6 +1389,47 @@ describe('ipc teams handlers', () => {
|
|||
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 () => {
|
||||
const electron = await import('electron');
|
||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||
|
|
@ -2130,6 +2327,7 @@ describe('ipc teams handlers', () => {
|
|||
description: undefined,
|
||||
color: undefined,
|
||||
});
|
||||
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
|
||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
'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', () => {
|
||||
it('calls service on valid input', async () => {
|
||||
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
||||
|
|
@ -2908,6 +3133,7 @@ describe('ipc teams handlers', () => {
|
|||
cwd: os.tmpdir(),
|
||||
})) as { success: boolean };
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('solo-team');
|
||||
});
|
||||
|
||||
it('handleCreateConfig preserves draft launch metadata', async () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
vi.useRealTimers();
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-'));
|
||||
|
|
|
|||
|
|
@ -51,7 +51,10 @@ function makeConfig(overrides: Partial<TeamConfig> = {}): TeamConfig {
|
|||
describe('CrossTeamService', () => {
|
||||
let service: CrossTeamService;
|
||||
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 provisioning: {
|
||||
isTeamAlive: ReturnType<typeof vi.fn>;
|
||||
|
|
@ -68,6 +71,7 @@ describe('CrossTeamService', () => {
|
|||
};
|
||||
dataService = {
|
||||
getLeadMemberName: vi.fn().mockResolvedValue('team-lead'),
|
||||
listTeams: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
inboxWriter = {
|
||||
sendMessage: vi.fn().mockResolvedValue({ deliveredToInbox: true, messageId: 'mock-id' }),
|
||||
|
|
@ -353,11 +357,65 @@ describe('CrossTeamService', () => {
|
|||
});
|
||||
|
||||
describe('listAvailableTargets', () => {
|
||||
it('returns empty when teams dir read fails', async () => {
|
||||
configReader.getConfig.mockRejectedValue(new Error('ENOENT'));
|
||||
it('returns empty when team summary listing fails', async () => {
|
||||
dataService.listTeams.mockRejectedValue(new Error('ENOENT'));
|
||||
const result = await service.listAvailableTargets();
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -200,6 +200,118 @@ describe('TeamConfigReader', () => {
|
|||
expect(teams[0]?.missingMembers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exposes lead summary fields without adding lead to teammate member chips', async () => {
|
||||
const teamName = 'lead-summary-team';
|
||||
const teamDir = path.join(tempDir, teamName);
|
||||
await fs.mkdir(teamDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({
|
||||
name: 'Lead Summary Team',
|
||||
members: [
|
||||
{ name: 'captain', agentType: 'team-lead', color: '#123456' },
|
||||
{ name: 'alice', role: 'reviewer', color: '#abcdef' },
|
||||
],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const reader = new TeamConfigReader();
|
||||
const teams = await reader.listTeams();
|
||||
|
||||
expect(teams).toHaveLength(1);
|
||||
expect(teams[0]).toMatchObject({
|
||||
teamName,
|
||||
displayName: 'Lead Summary Team',
|
||||
memberCount: 1,
|
||||
members: [{ name: 'alice', role: 'reviewer', color: '#abcdef' }],
|
||||
leadName: 'captain',
|
||||
leadColor: '#123456',
|
||||
});
|
||||
});
|
||||
|
||||
it('dedupes and briefly caches listTeams scans until invalidated', async () => {
|
||||
const teamName = 'cached-list-team';
|
||||
const teamDir = path.join(tempDir, teamName);
|
||||
await fs.mkdir(teamDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({
|
||||
name: 'Cached List Team',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
const readdirSpy = vi.spyOn(nodeFs.promises, 'readdir');
|
||||
|
||||
const reader = new TeamConfigReader();
|
||||
const [first, second] = await Promise.all([reader.listTeams(), reader.listTeams()]);
|
||||
const readdirAfterFirstBatch = readdirSpy.mock.calls.length;
|
||||
|
||||
expect(first).toHaveLength(1);
|
||||
expect(second).toHaveLength(1);
|
||||
|
||||
await reader.listTeams();
|
||||
expect(readdirSpy).toHaveBeenCalledTimes(readdirAfterFirstBatch);
|
||||
|
||||
TeamConfigReader.invalidateTeam(teamName);
|
||||
await reader.listTeams();
|
||||
expect(readdirSpy.mock.calls.length).toBeGreaterThan(readdirAfterFirstBatch);
|
||||
});
|
||||
|
||||
it('does not reuse a stale in-flight listTeams scan after invalidation', async () => {
|
||||
const teamName = 'inflight-invalidated-list-team';
|
||||
const teamDir = path.join(tempDir, teamName);
|
||||
await fs.mkdir(teamDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({
|
||||
name: 'Before Invalidation',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const firstReadStarted = createDeferred<void>();
|
||||
const releaseFirstRead = createDeferred<void>();
|
||||
const originalReaddir = nodeFs.promises.readdir.bind(nodeFs.promises);
|
||||
let blockedFirstTeamScan = false;
|
||||
const readdirSpy = vi
|
||||
.spyOn(nodeFs.promises, 'readdir')
|
||||
.mockImplementation(async (...args: unknown[]) => {
|
||||
if (!blockedFirstTeamScan && args[0] === tempDir) {
|
||||
blockedFirstTeamScan = true;
|
||||
firstReadStarted.resolve();
|
||||
await releaseFirstRead.promise;
|
||||
}
|
||||
return originalReaddir(...(args as Parameters<typeof nodeFs.promises.readdir>));
|
||||
});
|
||||
|
||||
const reader = new TeamConfigReader();
|
||||
const first = reader.listTeams();
|
||||
await firstReadStarted.promise;
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({
|
||||
name: 'After Invalidation',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
TeamConfigReader.invalidateTeam(teamName);
|
||||
|
||||
const second = reader.listTeams();
|
||||
await Promise.resolve();
|
||||
|
||||
const teamDirReads = readdirSpy.mock.calls.filter((call) => call[0] === tempDir);
|
||||
expect(teamDirReads.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
releaseFirstRead.resolve();
|
||||
const [, secondTeams] = await Promise.all([first, second]);
|
||||
expect(secondTeams[0]?.displayName).toBe('After Invalidation');
|
||||
});
|
||||
|
||||
it('does not let a removed base member hide an active auto-suffixed teammate in team summaries', async () => {
|
||||
const teamName = 'suffix-team';
|
||||
const teamDir = path.join(tempDir, teamName);
|
||||
|
|
@ -254,7 +366,7 @@ describe('TeamConfigReader', () => {
|
|||
JSON.stringify({
|
||||
version: 1,
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'team-lead', agentType: 'team-lead', color: '#123456' },
|
||||
{ name: 'alice', removedAt: Date.now() - 60_000 },
|
||||
{ name: 'bob', role: 'developer' },
|
||||
],
|
||||
|
|
@ -269,6 +381,46 @@ describe('TeamConfigReader', () => {
|
|||
teamName,
|
||||
displayName: 'Draft Summary Team',
|
||||
memberCount: 1,
|
||||
leadName: 'team-lead',
|
||||
leadColor: '#123456',
|
||||
pendingCreate: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses injected members meta store for draft team summaries', async () => {
|
||||
const teamName = 'draft-summary-injected-store-team';
|
||||
const teamDir = path.join(tempDir, teamName);
|
||||
await fs.mkdir(teamDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'team.meta.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
cwd: tempDir,
|
||||
displayName: 'Injected Draft Team',
|
||||
createdAt: Date.parse('2026-04-22T12:00:00.000Z'),
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'members.meta.json'),
|
||||
JSON.stringify({ version: 1, members: [] }),
|
||||
'utf8'
|
||||
);
|
||||
const getMembers = vi.fn(async () => [
|
||||
{ name: 'captain', agentType: 'team-lead', color: '#123456' },
|
||||
{ name: 'alice', role: 'developer' },
|
||||
]);
|
||||
|
||||
const reader = new TeamConfigReader({ getMembers } as never);
|
||||
const teams = await reader.listTeams();
|
||||
|
||||
expect(getMembers).toHaveBeenCalledWith(teamName);
|
||||
expect(teams[0]).toMatchObject({
|
||||
teamName,
|
||||
displayName: 'Injected Draft Team',
|
||||
memberCount: 1,
|
||||
leadName: 'captain',
|
||||
leadColor: '#123456',
|
||||
pendingCreate: true,
|
||||
});
|
||||
});
|
||||
|
|
@ -332,6 +484,59 @@ describe('TeamConfigReader', () => {
|
|||
expect(readFileSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('logs slow config reads with mode, likely cause, generation, and caller diagnostics', async () => {
|
||||
const teamName = 'slow-read-diagnostics-team';
|
||||
const teamDir = path.join(tempDir, teamName);
|
||||
const configPath = path.join(teamDir, 'config.json');
|
||||
await fs.mkdir(teamDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
name: 'Slow Diagnostics Team',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
vi.spyOn(performance, 'now')
|
||||
.mockReturnValueOnce(0)
|
||||
.mockReturnValueOnce(0)
|
||||
.mockReturnValueOnce(1)
|
||||
.mockReturnValueOnce(1)
|
||||
.mockReturnValueOnce(2_001)
|
||||
.mockReturnValueOnce(2_001)
|
||||
.mockReturnValueOnce(2_001)
|
||||
.mockReturnValueOnce(2_001)
|
||||
.mockReturnValueOnce(2_001);
|
||||
|
||||
const reader = new TeamConfigReader();
|
||||
expect((await reader.getConfigVerified(teamName))?.name).toBe('Slow Diagnostics Team');
|
||||
|
||||
const slowLog = warnSpy.mock.calls.find((call) =>
|
||||
String(call[1] ?? '').includes('[getConfig] slow read diag=')
|
||||
);
|
||||
expect(slowLog).toBeTruthy();
|
||||
const rawMessage = String(slowLog?.[1] ?? '');
|
||||
const diag = JSON.parse(rawMessage.slice(rawMessage.indexOf('diag=') + 'diag='.length)) as {
|
||||
mode: string;
|
||||
configPath: string;
|
||||
likelyCause: string;
|
||||
readMs: number;
|
||||
cacheGeneration: number;
|
||||
currentGeneration: number;
|
||||
caller: string | null;
|
||||
};
|
||||
expect(diag).toMatchObject({
|
||||
mode: 'verified',
|
||||
configPath,
|
||||
likelyCause: 'io_read_slow',
|
||||
readMs: 2000,
|
||||
cacheGeneration: 0,
|
||||
currentGeneration: 0,
|
||||
});
|
||||
expect(diag.caller).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shares in-flight snapshot stat and read work for concurrent calls', async () => {
|
||||
const teamName = 'snapshot-inflight-team';
|
||||
const teamDir = path.join(tempDir, teamName);
|
||||
|
|
@ -511,6 +716,54 @@ describe('TeamConfigReader', () => {
|
|||
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Fresh Prime');
|
||||
});
|
||||
|
||||
it('does not reuse stale in-flight verified reads after app-owned primeConfig', async () => {
|
||||
const teamName = 'verified-stale-read-prime-team';
|
||||
const teamDir = path.join(tempDir, teamName);
|
||||
const configPath = path.join(teamDir, 'config.json');
|
||||
await fs.mkdir(teamDir, { recursive: true });
|
||||
const staleRaw = JSON.stringify({
|
||||
name: 'Stale Verified Read',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
});
|
||||
await fs.writeFile(configPath, staleRaw, 'utf8');
|
||||
|
||||
const readDeferred = createDeferred<string>();
|
||||
const realReadFile = nodeFs.promises.readFile.bind(nodeFs.promises);
|
||||
let intercepted = false;
|
||||
vi.spyOn(nodeFs.promises, 'readFile').mockImplementation(
|
||||
((file: unknown, ...args: unknown[]) => {
|
||||
if (!intercepted && String(file) === configPath) {
|
||||
intercepted = true;
|
||||
return readDeferred.promise as never;
|
||||
}
|
||||
return realReadFile(file as never, ...(args as never[])) as never;
|
||||
}) as never
|
||||
);
|
||||
|
||||
const reader = new TeamConfigReader();
|
||||
const staleVerified = reader.getConfig(teamName);
|
||||
await vi.waitFor(() => expect(intercepted).toBe(true));
|
||||
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
name: 'Fresh Verified Prime',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
await TeamConfigReader.primeConfig(teamName, {
|
||||
name: 'Fresh Verified Prime',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
} as never);
|
||||
|
||||
expect((await reader.getConfig(teamName))?.name).toBe('Fresh Verified Prime');
|
||||
|
||||
readDeferred.resolve(staleRaw);
|
||||
expect((await staleVerified)?.name).toBe('Stale Verified Read');
|
||||
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Fresh Verified Prime');
|
||||
});
|
||||
|
||||
it('does not let stale in-flight snapshot read failures invalidate a primed config cache', async () => {
|
||||
const teamName = 'stale-read-failure-prime-team';
|
||||
const teamDir = path.join(tempDir, teamName);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { encodePath, setClaudeBasePathOverride } from '../../../../src/main/util
|
|||
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
|
||||
import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils';
|
||||
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 {
|
||||
|
|
@ -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', () => {
|
||||
it('round-trips create config metadata through getSavedRequest', async () => {
|
||||
const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-data-saved-request-'));
|
||||
|
|
@ -244,6 +348,7 @@ describe('TeamDataService draft metadata', () => {
|
|||
setClaudeBasePathOverride(claudeRoot);
|
||||
|
||||
const service = new TeamDataService();
|
||||
const listCacheInvalidateSpy = vi.spyOn(TeamConfigReader, 'invalidateListTeamsCache');
|
||||
await service.createTeamConfig({
|
||||
teamName: '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('draft-team')).resolves.toMatchObject({
|
||||
|
|
@ -1319,11 +1425,17 @@ describe('TeamDataService', () => {
|
|||
projectPath: '/Users/dev/my-project',
|
||||
members: [],
|
||||
}));
|
||||
const getConfigSnapshot = vi.fn(async () => ({
|
||||
name: 'My Team',
|
||||
projectPath: '/Users/dev/my-project',
|
||||
members: [],
|
||||
}));
|
||||
|
||||
const service = new TeamDataService(
|
||||
{
|
||||
listTeams: vi.fn(),
|
||||
getConfig,
|
||||
getConfigSnapshot,
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
|
|
@ -1343,7 +1455,8 @@ describe('TeamDataService', () => {
|
|||
displayName: 'My Team',
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const skipResponsesForOps = new Set<string>();
|
||||
const workers: Array<{
|
||||
messages: unknown[];
|
||||
handlers: Map<string, (value: unknown) => void>;
|
||||
|
|
@ -15,6 +16,7 @@ const hoisted = vi.hoisted(() => {
|
|||
postMessage(message: unknown) {
|
||||
worker.messages.push(message);
|
||||
const request = message as { id: string; op: string; payload?: { teamName?: string } };
|
||||
if (skipResponsesForOps.has(request.op)) return;
|
||||
queueMicrotask(() => {
|
||||
const handler = worker.handlers.get('message');
|
||||
if (!handler) return;
|
||||
|
|
@ -24,6 +26,8 @@ const hoisted = vi.hoisted(() => {
|
|||
result:
|
||||
request.op === 'getTeamData'
|
||||
? { teamName: request.payload?.teamName, config: { name: 'Team' } }
|
||||
: request.op === 'getMessagesPage'
|
||||
? { messages: [], nextCursor: null, hasMore: false, feedRevision: 'rev-1' }
|
||||
: null,
|
||||
});
|
||||
});
|
||||
|
|
@ -39,6 +43,7 @@ const hoisted = vi.hoisted(() => {
|
|||
return {
|
||||
workers,
|
||||
createMockWorker,
|
||||
skipResponsesForOps,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -61,7 +66,9 @@ describe('TeamDataWorkerClient', () => {
|
|||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
hoisted.workers.length = 0;
|
||||
hoisted.skipResponsesForOps.clear();
|
||||
});
|
||||
|
||||
it('deduplicates concurrent getTeamData calls for the same team', async () => {
|
||||
|
|
@ -107,6 +114,71 @@ describe('TeamDataWorkerClient', () => {
|
|||
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 () => {
|
||||
const { TeamDataWorkerClient } = await import(
|
||||
'../../../../src/main/services/team/TeamDataWorkerClient'
|
||||
|
|
@ -140,4 +212,23 @@ describe('TeamDataWorkerClient', () => {
|
|||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { existsSync } from 'fs';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
|
@ -18,11 +17,6 @@ interface WorkerResponse {
|
|||
let bundledWorkerPathPromise: Promise<string> | null = null;
|
||||
|
||||
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();
|
||||
return bundledWorkerPathPromise;
|
||||
}
|
||||
|
|
@ -230,7 +224,7 @@ describe('team-fs-worker integration', () => {
|
|||
JSON.stringify({
|
||||
version: 1,
|
||||
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: 'bob', role: 'developer' },
|
||||
],
|
||||
|
|
@ -246,6 +240,8 @@ describe('team-fs-worker integration', () => {
|
|||
teamName,
|
||||
displayName: 'Draft Worker Team',
|
||||
memberCount: 1,
|
||||
leadName: 'team-lead',
|
||||
leadColor: '#123456',
|
||||
});
|
||||
} finally {
|
||||
await worker.terminate();
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
const config: TeamConfig = {
|
||||
name: 'Signal Ops 4',
|
||||
|
|
@ -101,6 +111,75 @@ describe('TeamMessageFeedService', () => {
|
|||
).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 () => {
|
||||
const opencodeConfig: TeamConfig = {
|
||||
name: 'relay-works-14',
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ import {
|
|||
import { getTeamBootstrapStatePath } from '@main/services/team/TeamBootstrapStateReader';
|
||||
import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
|
||||
import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore';
|
||||
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||
import {
|
||||
getOpenCodeLaneScopedRuntimeFilePath,
|
||||
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 () => {
|
||||
allowConsoleLogs();
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||
|
|
@ -9158,6 +9191,7 @@ describe('TeamProvisioningService', () => {
|
|||
const teamName = 'mixed-opencode-post-launch-config';
|
||||
const teamDir = path.join(tempTeamsBase, teamName);
|
||||
const jackWorktree = path.join(tempClaudeRoot, 'worktrees', 'jack');
|
||||
const invalidateSpy = vi.spyOn(TeamConfigReader, 'invalidateTeam');
|
||||
fs.mkdirSync(teamDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'config.json'),
|
||||
|
|
@ -9228,6 +9262,7 @@ describe('TeamProvisioningService', () => {
|
|||
|
||||
expect(config.leadSessionId).toBe('new-lead-session');
|
||||
expect(config.projectPath).toBe(tempClaudeRoot);
|
||||
expect(invalidateSpy).toHaveBeenCalledWith(teamName);
|
||||
expect(config.members).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'team-lead',
|
||||
|
|
@ -9255,6 +9290,7 @@ describe('TeamProvisioningService', () => {
|
|||
}),
|
||||
]);
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,15 @@ const hoisted = vi.hoisted(() => {
|
|||
return {
|
||||
isFile: () => true,
|
||||
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,
|
||||
diagnostics: [],
|
||||
});
|
||||
const recipientSpy = vi.spyOn(service, 'isOpenCodeRuntimeRecipient');
|
||||
|
||||
const relay = await service.relayInboxFileToLiveRecipient(teamName, 'jack');
|
||||
|
||||
expect(relay).toMatchObject({ kind: 'opencode_member', relayed: 1 });
|
||||
expect(recipientSpy).toHaveBeenCalledTimes(1);
|
||||
const rows = JSON.parse(
|
||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
|
||||
);
|
||||
|
|
|
|||
62
test/main/services/team/TeamTaskReader.test.ts
Normal file
62
test/main/services/team/TeamTaskReader.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue