chore: merge dev into improve/v1.3
This commit is contained in:
commit
90d887fcce
40 changed files with 5236 additions and 504 deletions
|
|
@ -22,7 +22,7 @@ import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
|
||||||
import type { TeamMember } from '@shared/types';
|
import type { TeamMember } from '@shared/types';
|
||||||
|
|
||||||
export interface TeamTaskAgendaSourceDeps {
|
export interface TeamTaskAgendaSourceDeps {
|
||||||
configReader: TeamConfigReader;
|
configReader: Pick<TeamConfigReader, 'getConfig'>;
|
||||||
taskReader: TeamTaskReader;
|
taskReader: TeamTaskReader;
|
||||||
kanbanManager: TeamKanbanManager;
|
kanbanManager: TeamKanbanManager;
|
||||||
membersMetaStore: TeamMembersMetaStore;
|
membersMetaStore: TeamMembersMetaStore;
|
||||||
|
|
|
||||||
|
|
@ -124,8 +124,18 @@ export function createMemberWorkSyncFeature(deps: {
|
||||||
}): MemberWorkSyncFeatureFacade {
|
}): MemberWorkSyncFeatureFacade {
|
||||||
const clock = new SystemClockAdapter();
|
const clock = new SystemClockAdapter();
|
||||||
const hash = new NodeHashAdapter();
|
const hash = new NodeHashAdapter();
|
||||||
|
const configReaderForReadOnlySync = {
|
||||||
|
listTeams: () =>
|
||||||
|
typeof deps.configReader.listTeams === 'function'
|
||||||
|
? deps.configReader.listTeams()
|
||||||
|
: Promise.resolve([]),
|
||||||
|
getConfig: (teamName: string) =>
|
||||||
|
typeof deps.configReader.getConfigSnapshot === 'function'
|
||||||
|
? deps.configReader.getConfigSnapshot(teamName)
|
||||||
|
: deps.configReader.getConfig(teamName),
|
||||||
|
};
|
||||||
const agendaSource = new TeamTaskAgendaSource({
|
const agendaSource = new TeamTaskAgendaSource({
|
||||||
configReader: deps.configReader,
|
configReader: configReaderForReadOnlySync,
|
||||||
taskReader: deps.taskReader,
|
taskReader: deps.taskReader,
|
||||||
kanbanManager: deps.kanbanManager,
|
kanbanManager: deps.kanbanManager,
|
||||||
membersMetaStore: deps.membersMetaStore,
|
membersMetaStore: deps.membersMetaStore,
|
||||||
|
|
@ -150,7 +160,7 @@ export function createMemberWorkSyncFeature(deps: {
|
||||||
const runtimeTurnSettledTargetResolver =
|
const runtimeTurnSettledTargetResolver =
|
||||||
deps.runtimeTurnSettledTargetResolver ??
|
deps.runtimeTurnSettledTargetResolver ??
|
||||||
new TeamRuntimeTurnSettledTargetResolver({
|
new TeamRuntimeTurnSettledTargetResolver({
|
||||||
teamSource: deps.configReader,
|
teamSource: configReaderForReadOnlySync,
|
||||||
membersMetaStore: deps.membersMetaStore,
|
membersMetaStore: deps.membersMetaStore,
|
||||||
});
|
});
|
||||||
const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths);
|
const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths);
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ import {
|
||||||
resolveAgentTeamsMcpLaunchSpec,
|
resolveAgentTeamsMcpLaunchSpec,
|
||||||
TeamMcpConfigBuilder,
|
TeamMcpConfigBuilder,
|
||||||
} from '@main/services/team/TeamMcpConfigBuilder';
|
} from '@main/services/team/TeamMcpConfigBuilder';
|
||||||
|
import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver';
|
||||||
import { killTrackedCliProcesses } from '@main/utils/childProcess';
|
import { killTrackedCliProcesses } from '@main/utils/childProcess';
|
||||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||||
import {
|
import {
|
||||||
|
|
@ -137,12 +138,14 @@ import {
|
||||||
} from './services/team/TeamControlApiState';
|
} from './services/team/TeamControlApiState';
|
||||||
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
||||||
import { getTeamDataWorkerClient } from './services/team/TeamDataWorkerClient';
|
import { getTeamDataWorkerClient } from './services/team/TeamDataWorkerClient';
|
||||||
|
import { getTeamFsWorkerClient } from './services/team/TeamFsWorkerClient';
|
||||||
import { TeamMemberRuntimeAdvisoryService } from './services/team/TeamMemberRuntimeAdvisoryService';
|
import { TeamMemberRuntimeAdvisoryService } from './services/team/TeamMemberRuntimeAdvisoryService';
|
||||||
import {
|
import {
|
||||||
createTeamReconcileDrainScheduler,
|
createTeamReconcileDrainScheduler,
|
||||||
type TeamReconcileTrigger,
|
type TeamReconcileTrigger,
|
||||||
} from './services/team/TeamReconcileDrainScheduler';
|
} from './services/team/TeamReconcileDrainScheduler';
|
||||||
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
|
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
|
||||||
|
import { LaunchIoGovernor } from './services/team/LaunchIoGovernor';
|
||||||
import { getAppIconPath } from './utils/appIcon';
|
import { getAppIconPath } from './utils/appIcon';
|
||||||
import {
|
import {
|
||||||
getClaudeBasePath,
|
getClaudeBasePath,
|
||||||
|
|
@ -589,6 +592,7 @@ let runtimeProviderManagementFeature: RuntimeProviderManagementFeatureFacade;
|
||||||
let memberWorkSyncFeature: MemberWorkSyncFeatureFacade | null = null;
|
let memberWorkSyncFeature: MemberWorkSyncFeatureFacade | null = null;
|
||||||
let teamDataService: TeamDataService;
|
let teamDataService: TeamDataService;
|
||||||
let teamProvisioningService: TeamProvisioningService;
|
let teamProvisioningService: TeamProvisioningService;
|
||||||
|
let launchIoGovernor: LaunchIoGovernor | null = null;
|
||||||
let cliInstallerService: CliInstallerService;
|
let cliInstallerService: CliInstallerService;
|
||||||
let ptyTerminalService: PtyTerminalService;
|
let ptyTerminalService: PtyTerminalService;
|
||||||
let httpServer: HttpServer;
|
let httpServer: HttpServer;
|
||||||
|
|
@ -609,6 +613,8 @@ let shutdownComplete = false;
|
||||||
const startupTimers = new Set<ReturnType<typeof setTimeout>>();
|
const startupTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
const SHUTDOWN_STEP_TIMEOUT_MS = 5_000;
|
const SHUTDOWN_STEP_TIMEOUT_MS = 5_000;
|
||||||
|
const STARTUP_RECOVERY_DELAY_MS = 10_000;
|
||||||
|
const STARTUP_RECOVERY_CONCURRENCY = 1;
|
||||||
|
|
||||||
function isShutdownStarted(): boolean {
|
function isShutdownStarted(): boolean {
|
||||||
return shutdownComplete || shutdownPromise !== null;
|
return shutdownComplete || shutdownPromise !== null;
|
||||||
|
|
@ -626,6 +632,23 @@ function scheduleStartupTask(action: () => void, delayMs: number): void {
|
||||||
startupTimers.add(timer);
|
startupTimers.add(timer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runStartupJobsBounded<T>(
|
||||||
|
items: readonly T[],
|
||||||
|
concurrency: number,
|
||||||
|
run: (item: T) => Promise<void>
|
||||||
|
): Promise<void> {
|
||||||
|
const workerCount = Math.max(1, Math.min(concurrency, items.length));
|
||||||
|
const workers = Array.from({ length: workerCount }, async (_, workerIndex) => {
|
||||||
|
for (let index = workerIndex; index < items.length; index += workerCount) {
|
||||||
|
if (isShutdownStarted()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await run(items[index]!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.allSettled(workers);
|
||||||
|
}
|
||||||
|
|
||||||
function clearStartupTimers(): void {
|
function clearStartupTimers(): void {
|
||||||
for (const timer of startupTimers) {
|
for (const timer of startupTimers) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
|
@ -806,11 +829,21 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
if (typeof row.teamName !== 'string' || row.teamName.trim().length === 0) return;
|
if (typeof row.teamName !== 'string' || row.teamName.trim().length === 0) return;
|
||||||
const teamName = row.teamName.trim();
|
const teamName = row.teamName.trim();
|
||||||
const detail = typeof row.detail === 'string' ? row.detail : '';
|
const detail = typeof row.detail === 'string' ? row.detail : '';
|
||||||
|
launchIoGovernor?.noteTeamChange(row as TeamChangeEvent);
|
||||||
memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent);
|
memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent);
|
||||||
|
|
||||||
if (row.type === 'config' && detail === 'config.json') {
|
if (row.type === 'config') {
|
||||||
TeamConfigReader.invalidateTeam(teamName);
|
if (detail === 'config.json') {
|
||||||
getTeamDataWorkerClient().invalidateTeamConfig(teamName);
|
TeamConfigReader.invalidateTeam(teamName);
|
||||||
|
getTeamDataWorkerClient().invalidateTeamConfig(teamName);
|
||||||
|
} else if (detail === 'team.meta.json' || detail === 'members.meta.json') {
|
||||||
|
TeamConfigReader.invalidateListTeamsCache();
|
||||||
|
getTeamDataWorkerClient().invalidateTeamConfig(teamName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.type === 'task') {
|
||||||
|
TeamTaskReader.invalidateAllTasksCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -818,6 +851,9 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
(row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config')
|
(row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config')
|
||||||
) {
|
) {
|
||||||
teamDataService.invalidateMessageFeed(teamName);
|
teamDataService.invalidateMessageFeed(teamName);
|
||||||
|
if (row.type === 'inbox' || row.type === 'lead-message') {
|
||||||
|
getTeamDataWorkerClient().invalidateTeamMessageFeed(teamName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Inbox change events: relay to lead + native OS notifications ---
|
// --- Inbox change events: relay to lead + native OS notifications ---
|
||||||
|
|
@ -1038,6 +1074,10 @@ async function initializeServices(): Promise<void> {
|
||||||
// Set notification manager on local context's file watcher
|
// Set notification manager on local context's file watcher
|
||||||
localContext.fileWatcher.setNotificationManager(notificationManager);
|
localContext.fileWatcher.setNotificationManager(notificationManager);
|
||||||
|
|
||||||
|
launchIoGovernor = new LaunchIoGovernor({
|
||||||
|
logger: createLogger('Service:LaunchIoGovernor'),
|
||||||
|
});
|
||||||
|
|
||||||
// Wire file watcher events for local context
|
// Wire file watcher events for local context
|
||||||
wireFileWatcherEvents(localContext);
|
wireFileWatcherEvents(localContext);
|
||||||
|
|
||||||
|
|
@ -1060,7 +1100,12 @@ async function initializeServices(): Promise<void> {
|
||||||
ptyTerminalService = new PtyTerminalService();
|
ptyTerminalService = new PtyTerminalService();
|
||||||
const teamMemberLogsFinder = new TeamMemberLogsFinder();
|
const teamMemberLogsFinder = new TeamMemberLogsFinder();
|
||||||
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
|
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
|
||||||
const teamTranscriptSourceLocator = new TeamTranscriptSourceLocator();
|
const taskLogConfigReader = new TeamConfigReader();
|
||||||
|
const teamTranscriptSourceLocator = new TeamTranscriptSourceLocator(
|
||||||
|
new TeamTranscriptProjectResolver({
|
||||||
|
getConfig: (teamName) => taskLogConfigReader.getConfigSnapshot(teamName),
|
||||||
|
})
|
||||||
|
);
|
||||||
teamLogSourceTracker.onLogSourceChange((teamName) => {
|
teamLogSourceTracker.onLogSourceChange((teamName) => {
|
||||||
teamTranscriptSourceLocator.invalidateTeam(teamName);
|
teamTranscriptSourceLocator.invalidateTeam(teamName);
|
||||||
});
|
});
|
||||||
|
|
@ -1203,15 +1248,27 @@ async function initializeServices(): Promise<void> {
|
||||||
});
|
});
|
||||||
|
|
||||||
const forwardTeamChange = (event: TeamChangeEvent): void => {
|
const forwardTeamChange = (event: TeamChangeEvent): void => {
|
||||||
if (event.type === 'config' && event.detail === 'config.json') {
|
launchIoGovernor?.noteTeamChange(event);
|
||||||
TeamConfigReader.invalidateTeam(event.teamName);
|
if (event.type === 'config') {
|
||||||
getTeamDataWorkerClient().invalidateTeamConfig(event.teamName);
|
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 (
|
if (
|
||||||
teamDataService &&
|
teamDataService &&
|
||||||
(event.type === 'inbox' || event.type === 'lead-message' || event.type === 'config')
|
(event.type === 'inbox' || event.type === 'lead-message' || event.type === 'config')
|
||||||
) {
|
) {
|
||||||
teamDataService.invalidateMessageFeed(event.teamName);
|
teamDataService.invalidateMessageFeed(event.teamName);
|
||||||
|
if (event.type === 'inbox' || event.type === 'lead-message') {
|
||||||
|
getTeamDataWorkerClient().invalidateTeamMessageFeed(event.teamName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
|
safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
|
||||||
httpServer?.broadcast('team-change', event);
|
httpServer?.broadcast('team-change', event);
|
||||||
|
|
@ -1235,18 +1292,25 @@ async function initializeServices(): Promise<void> {
|
||||||
teamLogSourceTracker.onLogSourceChange((teamName) => {
|
teamLogSourceTracker.onLogSourceChange((teamName) => {
|
||||||
teammateToolTracker?.handleLogSourceChange(teamName);
|
teammateToolTracker?.handleLogSourceChange(teamName);
|
||||||
});
|
});
|
||||||
void teamDataService
|
scheduleStartupTask(() => {
|
||||||
.listTeams()
|
void teamDataService
|
||||||
.then(async (teams) => {
|
.listTeams()
|
||||||
await Promise.all(
|
.then(async (teams) => {
|
||||||
teams.map((team) =>
|
const activeTeamNames = teams
|
||||||
teamProvisioningService.scanOpenCodePromptDeliveryWatchdog(team.teamName)
|
.filter((team) => !team.deletedAt)
|
||||||
)
|
.map((team) => team.teamName);
|
||||||
|
await runStartupJobsBounded(
|
||||||
|
activeTeamNames,
|
||||||
|
STARTUP_RECOVERY_CONCURRENCY,
|
||||||
|
async (teamName) => {
|
||||||
|
await teamProvisioningService.scanOpenCodePromptDeliveryWatchdog(teamName);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) =>
|
||||||
|
logger.warn(`[Init] OpenCode prompt delivery watchdog recovery failed: ${String(error)}`)
|
||||||
);
|
);
|
||||||
})
|
}, STARTUP_RECOVERY_DELAY_MS);
|
||||||
.catch((error: unknown) =>
|
|
||||||
logger.warn(`[Init] OpenCode prompt delivery watchdog recovery failed: ${String(error)}`)
|
|
||||||
);
|
|
||||||
teamTaskStallMonitor.start();
|
teamTaskStallMonitor.start();
|
||||||
|
|
||||||
// Allow SchedulerService to push schedule events to renderer
|
// Allow SchedulerService to push schedule events to renderer
|
||||||
|
|
@ -1301,16 +1365,25 @@ async function initializeServices(): Promise<void> {
|
||||||
? memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment(input)
|
? memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment(input)
|
||||||
: Promise.resolve(null)
|
: Promise.resolve(null)
|
||||||
);
|
);
|
||||||
void teamDataService
|
scheduleStartupTask(() => {
|
||||||
.listTeams()
|
void teamDataService
|
||||||
.then(async (teams) => {
|
.listTeams()
|
||||||
const activeTeamNames = teams.filter((team) => !team.deletedAt).map((team) => team.teamName);
|
.then(async (teams) => {
|
||||||
await memberWorkSyncFeature?.replayPendingReports(activeTeamNames);
|
const lifecycleActiveTeamNames = teams
|
||||||
await memberWorkSyncFeature?.enqueueStartupScan(activeTeamNames);
|
.filter(
|
||||||
})
|
(team) =>
|
||||||
.catch((error: unknown) =>
|
!team.deletedAt &&
|
||||||
logger.warn(`[Init] Member work sync startup scan failed: ${String(error)}`)
|
(teamProvisioningService.isTeamAlive(team.teamName) ||
|
||||||
);
|
teamProvisioningService.hasProvisioningRun(team.teamName))
|
||||||
|
)
|
||||||
|
.map((team) => team.teamName);
|
||||||
|
await memberWorkSyncFeature?.replayPendingReports(lifecycleActiveTeamNames);
|
||||||
|
await memberWorkSyncFeature?.enqueueStartupScan(lifecycleActiveTeamNames);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) =>
|
||||||
|
logger.warn(`[Init] Member work sync startup scan failed: ${String(error)}`)
|
||||||
|
);
|
||||||
|
}, STARTUP_RECOVERY_DELAY_MS + 2_000);
|
||||||
codexAccountFeature = createCodexAccountFeature({
|
codexAccountFeature = createCodexAccountFeature({
|
||||||
logger: createLogger('Feature:CodexAccount'),
|
logger: createLogger('Feature:CodexAccount'),
|
||||||
configManager,
|
configManager,
|
||||||
|
|
@ -1373,7 +1446,8 @@ async function initializeServices(): Promise<void> {
|
||||||
skillsMutationService,
|
skillsMutationService,
|
||||||
skillsWatcherService,
|
skillsWatcherService,
|
||||||
crossTeamService,
|
crossTeamService,
|
||||||
teamBackupService ?? undefined
|
teamBackupService ?? undefined,
|
||||||
|
launchIoGovernor ?? undefined
|
||||||
);
|
);
|
||||||
registerCodexAccountIpc(ipcMain, codexAccountFeature);
|
registerCodexAccountIpc(ipcMain, codexAccountFeature);
|
||||||
registerRecentProjectsIpc(ipcMain, recentProjectsFeature);
|
registerRecentProjectsIpc(ipcMain, recentProjectsFeature);
|
||||||
|
|
@ -1768,6 +1842,30 @@ function createWindow(): void {
|
||||||
updaterService.startPeriodicCheck(60 * 60 * 1000);
|
updaterService.startPeriodicCheck(60 * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheduleStartupTask(
|
||||||
|
() => {
|
||||||
|
void getTeamFsWorkerClient()
|
||||||
|
.prewarm()
|
||||||
|
.catch((error: unknown) =>
|
||||||
|
logger.debug(
|
||||||
|
`[startup] team-fs-worker prewarm skipped: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
void getTeamDataWorkerClient()
|
||||||
|
.prewarm()
|
||||||
|
.catch((error: unknown) =>
|
||||||
|
logger.debug(
|
||||||
|
`[startup] team-data-worker prewarm skipped: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
process.platform === 'win32' ? 2500 : 1000
|
||||||
|
);
|
||||||
|
|
||||||
// Defer non-critical startup work to avoid thread pool contention.
|
// Defer non-critical startup work to avoid thread pool contention.
|
||||||
// The window is now visible and responsive; these run in the background.
|
// The window is now visible and responsive; these run in the background.
|
||||||
scheduleStartupTask(() => {
|
scheduleStartupTask(() => {
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ import type { McpHealthDiagnosticsService } from '../services/extensions/state/M
|
||||||
import type { HttpServer } from '../services/infrastructure/HttpServer';
|
import type { HttpServer } from '../services/infrastructure/HttpServer';
|
||||||
import type { SchedulerService } from '../services/schedule/SchedulerService';
|
import type { SchedulerService } from '../services/schedule/SchedulerService';
|
||||||
import type { CrossTeamService } from '../services/team/CrossTeamService';
|
import type { CrossTeamService } from '../services/team/CrossTeamService';
|
||||||
|
import type { LaunchIoGovernor } from '../services/team/LaunchIoGovernor';
|
||||||
import type { TeamBackupService } from '../services/team/TeamBackupService';
|
import type { TeamBackupService } from '../services/team/TeamBackupService';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -169,7 +170,8 @@ export function initializeIpcHandlers(
|
||||||
skillsMutationService?: SkillsMutationService,
|
skillsMutationService?: SkillsMutationService,
|
||||||
skillsWatcherService?: SkillsWatcherService,
|
skillsWatcherService?: SkillsWatcherService,
|
||||||
crossTeamService?: CrossTeamService,
|
crossTeamService?: CrossTeamService,
|
||||||
teamBackupService?: TeamBackupService
|
teamBackupService?: TeamBackupService,
|
||||||
|
launchIoGovernor?: LaunchIoGovernor
|
||||||
): void {
|
): void {
|
||||||
// Initialize domain handlers with registry
|
// Initialize domain handlers with registry
|
||||||
initializeProjectHandlers(registry);
|
initializeProjectHandlers(registry);
|
||||||
|
|
@ -192,7 +194,8 @@ export function initializeIpcHandlers(
|
||||||
boardTaskActivityDetailService,
|
boardTaskActivityDetailService,
|
||||||
boardTaskLogStreamService,
|
boardTaskLogStreamService,
|
||||||
boardTaskExactLogsService,
|
boardTaskExactLogsService,
|
||||||
boardTaskExactLogDetailService
|
boardTaskExactLogDetailService,
|
||||||
|
launchIoGovernor
|
||||||
);
|
);
|
||||||
initializeConfigHandlers({
|
initializeConfigHandlers({
|
||||||
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
|
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,10 @@ import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
||||||
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
||||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||||
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
|
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
|
||||||
|
import {
|
||||||
|
cloneLaunchIoGovernorPayload,
|
||||||
|
type LaunchIoGovernor,
|
||||||
|
} from '../services/team/LaunchIoGovernor';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
validateFromField,
|
validateFromField,
|
||||||
|
|
@ -220,6 +224,7 @@ import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser';
|
||||||
|
|
||||||
const logger = createLogger('IPC:teams');
|
const logger = createLogger('IPC:teams');
|
||||||
const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 12_000;
|
const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 12_000;
|
||||||
|
const TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS = 250;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory set of rate-limit message keys already processed.
|
* In-memory set of rate-limit message keys already processed.
|
||||||
|
|
@ -511,6 +516,7 @@ let teamBackupService: TeamBackupService | null = null;
|
||||||
let teammateToolTracker: TeammateToolTracker | null = null;
|
let teammateToolTracker: TeammateToolTracker | null = null;
|
||||||
let teamLogSourceTracker: TeamLogSourceTracker | null = null;
|
let teamLogSourceTracker: TeamLogSourceTracker | null = null;
|
||||||
let branchStatusService: BranchStatusService | null = null;
|
let branchStatusService: BranchStatusService | null = null;
|
||||||
|
let launchIoGovernor: LaunchIoGovernor | null = null;
|
||||||
let boardTaskActivityService: BoardTaskActivityService | null = null;
|
let boardTaskActivityService: BoardTaskActivityService | null = null;
|
||||||
let boardTaskActivityDetailService: BoardTaskActivityDetailService | null = null;
|
let boardTaskActivityDetailService: BoardTaskActivityDetailService | null = null;
|
||||||
let boardTaskLogStreamService: BoardTaskLogStreamService | null = null;
|
let boardTaskLogStreamService: BoardTaskLogStreamService | null = null;
|
||||||
|
|
@ -563,7 +569,8 @@ export function initializeTeamHandlers(
|
||||||
taskActivityDetailService?: BoardTaskActivityDetailService,
|
taskActivityDetailService?: BoardTaskActivityDetailService,
|
||||||
taskLogStreamService?: BoardTaskLogStreamService,
|
taskLogStreamService?: BoardTaskLogStreamService,
|
||||||
taskExactLogsService?: BoardTaskExactLogsService,
|
taskExactLogsService?: BoardTaskExactLogsService,
|
||||||
taskExactLogDetailService?: BoardTaskExactLogDetailService
|
taskExactLogDetailService?: BoardTaskExactLogDetailService,
|
||||||
|
ioGovernor?: LaunchIoGovernor
|
||||||
): void {
|
): void {
|
||||||
teamDataService = service;
|
teamDataService = service;
|
||||||
teamProvisioningService = provisioningService;
|
teamProvisioningService = provisioningService;
|
||||||
|
|
@ -574,6 +581,7 @@ export function initializeTeamHandlers(
|
||||||
teammateToolTracker = toolTracker ?? null;
|
teammateToolTracker = toolTracker ?? null;
|
||||||
teamLogSourceTracker = logSourceTracker ?? null;
|
teamLogSourceTracker = logSourceTracker ?? null;
|
||||||
branchStatusService = branchTracker ?? null;
|
branchStatusService = branchTracker ?? null;
|
||||||
|
launchIoGovernor = ioGovernor ?? null;
|
||||||
boardTaskActivityService = taskActivityService ?? null;
|
boardTaskActivityService = taskActivityService ?? null;
|
||||||
boardTaskActivityDetailService = taskActivityDetailService ?? null;
|
boardTaskActivityDetailService = taskActivityDetailService ?? null;
|
||||||
boardTaskLogStreamService = taskLogStreamService ?? null;
|
boardTaskLogStreamService = taskLogStreamService ?? null;
|
||||||
|
|
@ -895,7 +903,14 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<Te
|
||||||
setCurrentMainOp('team:list');
|
setCurrentMainOp('team:list');
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
return await wrapTeamHandler('list', () => getTeamDataService().listTeams());
|
return await wrapTeamHandler('list', () => {
|
||||||
|
const loadFresh = () => getTeamDataService().listTeams();
|
||||||
|
return launchIoGovernor
|
||||||
|
? launchIoGovernor.runSummaryOperation('teams:list', loadFresh, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
})
|
||||||
|
: loadFresh();
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
const ms = Date.now() - startedAt;
|
const ms = Date.now() - startedAt;
|
||||||
if (ms >= 1500) {
|
if (ms >= 1500) {
|
||||||
|
|
@ -916,35 +931,53 @@ async function handleGetData(
|
||||||
const tn = validated.value!;
|
const tn = validated.value!;
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let data: TeamViewSnapshot;
|
let data: TeamViewSnapshot;
|
||||||
|
let dataSource: 'worker' | 'main-fallback' | 'main-unavailable' = 'main-unavailable';
|
||||||
|
let workerAvailable = false;
|
||||||
setCurrentMainOp('team:getData');
|
setCurrentMainOp('team:getData');
|
||||||
try {
|
try {
|
||||||
// Prefer worker thread to keep main event loop responsive
|
// Prefer worker thread to keep main event loop responsive
|
||||||
const worker = getTeamDataWorkerClient();
|
const worker = getTeamDataWorkerClient();
|
||||||
if (worker.isAvailable()) {
|
workerAvailable = worker.isAvailable();
|
||||||
|
const missingState = await classifyMissingTeamData(tn);
|
||||||
|
if (missingState === 'provisioning') {
|
||||||
|
return { success: false, error: 'TEAM_PROVISIONING' };
|
||||||
|
}
|
||||||
|
if (missingState === 'draft') {
|
||||||
|
return { success: false, error: 'TEAM_DRAFT' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workerAvailable) {
|
||||||
try {
|
try {
|
||||||
data = await worker.getTeamData(tn);
|
data = await worker.getTeamData(tn);
|
||||||
|
dataSource = 'worker';
|
||||||
} catch (workerErr) {
|
} catch (workerErr) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
|
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
|
||||||
);
|
);
|
||||||
noteHeavyTeamDataWorkerFallback('teams:getData');
|
noteHeavyTeamDataWorkerFallback('teams:getData');
|
||||||
data = await getTeamDataService().getTeamData(tn);
|
data = await getTeamDataService().getTeamData(tn);
|
||||||
|
dataSource = 'main-fallback';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
noteHeavyTeamDataWorkerFallback('teams:getData');
|
noteHeavyTeamDataWorkerFallback('teams:getData');
|
||||||
data = await getTeamDataService().getTeamData(tn);
|
data = await getTeamDataService().getTeamData(tn);
|
||||||
|
dataSource = 'main-unavailable';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
if (
|
if (
|
||||||
message === `Team not found: ${tn}` &&
|
message === `Team not found: ${tn}` &&
|
||||||
getTeamProvisioningService().hasProvisioningRun(tn)
|
getTeamProvisioningService().hasProvisioningRun?.(tn) === true
|
||||||
) {
|
) {
|
||||||
return { success: false, error: 'TEAM_PROVISIONING' };
|
return { success: false, error: 'TEAM_PROVISIONING' };
|
||||||
}
|
}
|
||||||
// Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate)
|
// Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate)
|
||||||
if (message === `Team not found: ${tn}`) {
|
if (message === `Team not found: ${tn}`) {
|
||||||
const meta = await teamMetaStore.getMeta(tn);
|
const meta = await withTimeoutValue(
|
||||||
|
teamMetaStore.getMeta(tn).catch(() => null),
|
||||||
|
TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS,
|
||||||
|
null
|
||||||
|
);
|
||||||
if (meta) {
|
if (meta) {
|
||||||
return { success: false, error: 'TEAM_DRAFT' };
|
return { success: false, error: 'TEAM_DRAFT' };
|
||||||
}
|
}
|
||||||
|
|
@ -957,7 +990,9 @@ async function handleGetData(
|
||||||
const getDataMs = Date.now() - startedAt;
|
const getDataMs = Date.now() - startedAt;
|
||||||
|
|
||||||
if (getDataMs >= 1500) {
|
if (getDataMs >= 1500) {
|
||||||
logger.warn(`[teams:getData] slow team=${tn} ms=${getDataMs}`);
|
logger.warn(
|
||||||
|
`[teams:getData] slow team=${tn} ms=${getDataMs} source=${dataSource} workerAvailable=${workerAvailable}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const teamDataService = getTeamDataService();
|
const teamDataService = getTeamDataService();
|
||||||
if (data.processes.some((process) => !process.stoppedAt)) {
|
if (data.processes.some((process) => !process.stoppedAt)) {
|
||||||
|
|
@ -1015,6 +1050,33 @@ async function handleGetData(
|
||||||
return { success: true, data: { ...data, isAlive } };
|
return { success: true, data: { ...data, isAlive } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function classifyMissingTeamData(teamName: string): Promise<'provisioning' | 'draft' | null> {
|
||||||
|
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||||
|
const configExists = await withTimeoutValue(
|
||||||
|
fs.promises
|
||||||
|
.access(configPath, fs.constants.F_OK)
|
||||||
|
.then(() => true)
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const code = typeof error === 'object' && error ? (error as { code?: unknown }).code : null;
|
||||||
|
return code === 'ENOENT' ? false : null;
|
||||||
|
}),
|
||||||
|
TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
if (configExists !== false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (getTeamProvisioningService().hasProvisioningRun?.(teamName) === true) {
|
||||||
|
return 'provisioning';
|
||||||
|
}
|
||||||
|
const meta = await withTimeoutValue(
|
||||||
|
teamMetaStore.getMeta(teamName).catch(() => null),
|
||||||
|
TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
return meta ? 'draft' : null;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleGetTaskChangePresence(
|
async function handleGetTaskChangePresence(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown
|
teamName: unknown
|
||||||
|
|
@ -1116,6 +1178,7 @@ async function handleDeleteTeam(
|
||||||
getAutoResumeService().cancelPendingAutoResume(validated.value!);
|
getAutoResumeService().cancelPendingAutoResume(validated.value!);
|
||||||
await getTeamProvisioningService().stopTeam(validated.value!);
|
await getTeamProvisioningService().stopTeam(validated.value!);
|
||||||
await getTeamDataService().deleteTeam(validated.value!);
|
await getTeamDataService().deleteTeam(validated.value!);
|
||||||
|
getTeamDataWorkerClient().invalidateTeamConfig(validated.value!);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1127,7 +1190,10 @@ async function handleRestoreTeam(
|
||||||
if (!validated.valid) {
|
if (!validated.valid) {
|
||||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||||
}
|
}
|
||||||
return wrapTeamHandler('restoreTeam', () => getTeamDataService().restoreTeam(validated.value!));
|
return wrapTeamHandler('restoreTeam', async () => {
|
||||||
|
await getTeamDataService().restoreTeam(validated.value!);
|
||||||
|
getTeamDataWorkerClient().invalidateTeamConfig(validated.value!);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePermanentlyDeleteTeam(
|
async function handlePermanentlyDeleteTeam(
|
||||||
|
|
@ -1141,6 +1207,7 @@ async function handlePermanentlyDeleteTeam(
|
||||||
return wrapTeamHandler('permanentlyDeleteTeam', async () => {
|
return wrapTeamHandler('permanentlyDeleteTeam', async () => {
|
||||||
getAutoResumeService().cancelPendingAutoResume(validated.value!);
|
getAutoResumeService().cancelPendingAutoResume(validated.value!);
|
||||||
await getTeamDataService().permanentlyDeleteTeam(validated.value!);
|
await getTeamDataService().permanentlyDeleteTeam(validated.value!);
|
||||||
|
getTeamDataWorkerClient().invalidateTeamConfig(validated.value!);
|
||||||
// Clean up app-owned data (attachments, task-attachments) that lives outside ~/.claude/
|
// Clean up app-owned data (attachments, task-attachments) that lives outside ~/.claude/
|
||||||
const appData = getAppDataPath();
|
const appData = getAppDataPath();
|
||||||
await fs.promises
|
await fs.promises
|
||||||
|
|
@ -1208,6 +1275,7 @@ async function handleUpdateConfig(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getTeamDataWorkerClient().invalidateTeamConfig(tn);
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1797,6 +1865,21 @@ function sendProvisioningProgress(
|
||||||
safeSendToRenderer(targetWindow, TEAM_PROVISIONING_PROGRESS, progress);
|
safeSendToRenderer(targetWindow, TEAM_PROVISIONING_PROGRESS, progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function noteLaunchIntentFailed(teamName: string, source: string): void {
|
||||||
|
if (!launchIoGovernor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
launchIoGovernor.noteProvisioningProgress({
|
||||||
|
runId: `${source}:failed-before-progress`,
|
||||||
|
teamName,
|
||||||
|
state: 'failed',
|
||||||
|
message: 'Launch failed before provisioning progress',
|
||||||
|
startedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
} as TeamProvisioningProgress);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreateTeam(
|
async function handleCreateTeam(
|
||||||
event: IpcMainInvokeEvent,
|
event: IpcMainInvokeEvent,
|
||||||
request: unknown
|
request: unknown
|
||||||
|
|
@ -1807,11 +1890,18 @@ async function handleCreateTeam(
|
||||||
}
|
}
|
||||||
const progressTargetWindow = BrowserWindow.fromWebContents(event.sender);
|
const progressTargetWindow = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
|
||||||
return wrapTeamHandler('create', () => {
|
return wrapTeamHandler('create', async () => {
|
||||||
addMainBreadcrumb('team', 'create', { teamName: validation.value.teamName });
|
addMainBreadcrumb('team', 'create', { teamName: validation.value.teamName });
|
||||||
return getTeamProvisioningService().createTeam(validation.value, (progress) => {
|
launchIoGovernor?.noteLaunchIntent(validation.value.teamName, 'create');
|
||||||
sendProvisioningProgress(progressTargetWindow, progress);
|
try {
|
||||||
});
|
return await getTeamProvisioningService().createTeam(validation.value, (progress) => {
|
||||||
|
launchIoGovernor?.noteProvisioningProgress(progress);
|
||||||
|
sendProvisioningProgress(progressTargetWindow, progress);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
noteLaunchIntentFailed(validation.value.teamName, 'create');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1944,11 +2034,18 @@ async function handleLaunchTeam(
|
||||||
members: savedRequest.members,
|
members: savedRequest.members,
|
||||||
};
|
};
|
||||||
|
|
||||||
return wrapTeamHandler('create', () =>
|
return wrapTeamHandler('create', async () => {
|
||||||
getTeamProvisioningService().createTeam(createRequest, (progress) => {
|
launchIoGovernor?.noteLaunchIntent(tn, 'draft-launch');
|
||||||
sendProvisioningProgress(progressTargetWindow, progress);
|
try {
|
||||||
})
|
return await getTeamProvisioningService().createTeam(createRequest, (progress) => {
|
||||||
);
|
launchIoGovernor?.noteProvisioningProgress(progress);
|
||||||
|
sendProvisioningProgress(progressTargetWindow, progress);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
noteLaunchIntentFailed(tn, 'draft-launch');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const persistedMeta = await teamMetaStore.getMeta(tn).catch(() => null);
|
const persistedMeta = await teamMetaStore.getMeta(tn).catch(() => null);
|
||||||
|
|
@ -1986,33 +2083,41 @@ async function handleLaunchTeam(
|
||||||
const launchLimitContext =
|
const launchLimitContext =
|
||||||
typeof payload.limitContext === 'boolean' ? payload.limitContext : persistedMeta?.limitContext;
|
typeof payload.limitContext === 'boolean' ? payload.limitContext : persistedMeta?.limitContext;
|
||||||
|
|
||||||
return wrapTeamHandler('launch', () => {
|
return wrapTeamHandler('launch', async () => {
|
||||||
addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! });
|
addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! });
|
||||||
return getTeamProvisioningService().launchTeam(
|
launchIoGovernor?.noteLaunchIntent(validatedTeamName.value!, 'launch');
|
||||||
{
|
try {
|
||||||
teamName: validatedTeamName.value!,
|
return await getTeamProvisioningService().launchTeam(
|
||||||
cwd,
|
{
|
||||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
teamName: validatedTeamName.value!,
|
||||||
providerId: launchProviderId,
|
cwd,
|
||||||
providerBackendId: launchProviderBackendValidation.value,
|
prompt:
|
||||||
model: rawLaunchModel,
|
typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||||
effort: effortValidation.value,
|
providerId: launchProviderId,
|
||||||
fastMode: fastModeValidation.value,
|
providerBackendId: launchProviderBackendValidation.value,
|
||||||
limitContext: launchLimitContext,
|
model: rawLaunchModel,
|
||||||
clearContext: payload.clearContext === true ? true : undefined,
|
effort: effortValidation.value,
|
||||||
skipPermissions:
|
fastMode: fastModeValidation.value,
|
||||||
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
limitContext: launchLimitContext,
|
||||||
worktree:
|
clearContext: payload.clearContext === true ? true : undefined,
|
||||||
typeof payload.worktree === 'string' ? payload.worktree.trim() || undefined : undefined,
|
skipPermissions:
|
||||||
extraCliArgs:
|
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
||||||
typeof payload.extraCliArgs === 'string'
|
worktree:
|
||||||
? payload.extraCliArgs.trim() || undefined
|
typeof payload.worktree === 'string' ? payload.worktree.trim() || undefined : undefined,
|
||||||
: undefined,
|
extraCliArgs:
|
||||||
},
|
typeof payload.extraCliArgs === 'string'
|
||||||
(progress) => {
|
? payload.extraCliArgs.trim() || undefined
|
||||||
sendProvisioningProgress(progressTargetWindow, progress);
|
: undefined,
|
||||||
}
|
},
|
||||||
);
|
(progress) => {
|
||||||
|
launchIoGovernor?.noteProvisioningProgress(progress);
|
||||||
|
sendProvisioningProgress(progressTargetWindow, progress);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
noteLaunchIntentFailed(validatedTeamName.value!, 'launch');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2352,35 +2457,47 @@ async function handleGetMessagesPage(
|
||||||
|
|
||||||
return wrapTeamHandler('getMessagesPage', async () => {
|
return wrapTeamHandler('getMessagesPage', async () => {
|
||||||
let page: MessagesPage;
|
let page: MessagesPage;
|
||||||
const notificationContext = await getTeamDataService().getTeamNotificationContext(vTeam.value!);
|
const teamName = vTeam.value!;
|
||||||
|
const scanNotifications = (messagesPage: MessagesPage): void => {
|
||||||
|
const notificationContextPromise: Promise<{ displayName: string; projectPath?: string }> =
|
||||||
|
getTeamDataService()
|
||||||
|
.getTeamNotificationContext(teamName)
|
||||||
|
.catch(() => ({ displayName: teamName }));
|
||||||
|
void notificationContextPromise
|
||||||
|
.then((notificationContext) => {
|
||||||
|
scanTeamMessageNotifications(
|
||||||
|
messagesPage.messages,
|
||||||
|
teamName,
|
||||||
|
notificationContext.displayName,
|
||||||
|
notificationContext.projectPath
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
logger.debug(
|
||||||
|
`[teams:getMessagesPage] notification scan skipped team=${teamName}: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
const liveMessages =
|
const liveMessages =
|
||||||
cursor == null ? getTeamProvisioningService().getLiveLeadProcessMessages(vTeam.value!) : [];
|
cursor == null ? getTeamProvisioningService().getLiveLeadProcessMessages(teamName) : [];
|
||||||
|
|
||||||
if (liveMessages.length > 0) {
|
if (liveMessages.length > 0) {
|
||||||
page = await getTeamDataService().getMessagesPage(vTeam.value!, {
|
page = await getTeamDataService().getMessagesPage(teamName, {
|
||||||
cursor,
|
cursor,
|
||||||
limit,
|
limit,
|
||||||
liveMessages,
|
liveMessages,
|
||||||
});
|
});
|
||||||
scanTeamMessageNotifications(
|
scanNotifications(page);
|
||||||
page.messages,
|
|
||||||
vTeam.value!,
|
|
||||||
notificationContext.displayName,
|
|
||||||
notificationContext.projectPath
|
|
||||||
);
|
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
const worker = getTeamDataWorkerClient();
|
const worker = getTeamDataWorkerClient();
|
||||||
if (worker.isAvailable()) {
|
if (worker.isAvailable()) {
|
||||||
try {
|
try {
|
||||||
page = await worker.getMessagesPage(vTeam.value!, { cursor, limit });
|
page = await worker.getMessagesPage(teamName, { cursor, limit });
|
||||||
scanTeamMessageNotifications(
|
scanNotifications(page);
|
||||||
page.messages,
|
|
||||||
vTeam.value!,
|
|
||||||
notificationContext.displayName,
|
|
||||||
notificationContext.projectPath
|
|
||||||
);
|
|
||||||
return page;
|
return page;
|
||||||
} catch (workerErr) {
|
} catch (workerErr) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|
@ -2391,13 +2508,8 @@ async function handleGetMessagesPage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
noteHeavyTeamDataWorkerFallback('teams:getMessagesPage');
|
noteHeavyTeamDataWorkerFallback('teams:getMessagesPage');
|
||||||
page = await getTeamDataService().getMessagesPage(vTeam.value!, { cursor, limit });
|
page = await getTeamDataService().getMessagesPage(teamName, { cursor, limit });
|
||||||
scanTeamMessageNotifications(
|
scanNotifications(page);
|
||||||
page.messages,
|
|
||||||
vTeam.value!,
|
|
||||||
notificationContext.displayName,
|
|
||||||
notificationContext.projectPath
|
|
||||||
);
|
|
||||||
return page;
|
return page;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -3307,8 +3419,8 @@ async function handleCreateConfig(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return wrapTeamHandler('createConfig', () =>
|
return wrapTeamHandler('createConfig', async () => {
|
||||||
getTeamDataService().createTeamConfig({
|
await getTeamDataService().createTeamConfig({
|
||||||
teamName,
|
teamName,
|
||||||
displayName: payload.displayName?.trim() || undefined,
|
displayName: payload.displayName?.trim() || undefined,
|
||||||
description: payload.description?.trim() || undefined,
|
description: payload.description?.trim() || undefined,
|
||||||
|
|
@ -3332,8 +3444,9 @@ async function handleCreateConfig(
|
||||||
typeof payload.extraCliArgs === 'string' && payload.extraCliArgs.trim()
|
typeof payload.extraCliArgs === 'string' && payload.extraCliArgs.trim()
|
||||||
? payload.extraCliArgs.trim()
|
? payload.extraCliArgs.trim()
|
||||||
: undefined,
|
: undefined,
|
||||||
})
|
});
|
||||||
);
|
getTeamDataWorkerClient().invalidateTeamConfig(teamName);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTeamMemberLogsFinder(): TeamMemberLogsFinder {
|
function getTeamMemberLogsFinder(): TeamMemberLogsFinder {
|
||||||
|
|
@ -3724,7 +3837,14 @@ async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise<IpcResult<
|
||||||
setCurrentMainOp('team:getAllTasks');
|
setCurrentMainOp('team:getAllTasks');
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
return await wrapTeamHandler('getAllTasks', () => getTeamDataService().getAllTasks());
|
return await wrapTeamHandler('getAllTasks', () => {
|
||||||
|
const loadFresh = () => getTeamDataService().getAllTasks();
|
||||||
|
return launchIoGovernor
|
||||||
|
? launchIoGovernor.runSummaryOperation('teams:getAllTasks', loadFresh, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
})
|
||||||
|
: loadFresh();
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
const ms = Date.now() - startedAt;
|
const ms = Date.now() - startedAt;
|
||||||
if (ms >= 1500) {
|
if (ms >= 1500) {
|
||||||
|
|
|
||||||
|
|
@ -1008,7 +1008,8 @@ export class FileWatcher extends EventEmitter {
|
||||||
if (
|
if (
|
||||||
relative === 'config.json' ||
|
relative === 'config.json' ||
|
||||||
relative === 'kanban-state.json' ||
|
relative === 'kanban-state.json' ||
|
||||||
relative === 'team.meta.json'
|
relative === 'team.meta.json' ||
|
||||||
|
relative === 'members.meta.json'
|
||||||
) {
|
) {
|
||||||
const event: TeamChangeEvent = {
|
const event: TeamChangeEvent = {
|
||||||
type: 'config',
|
type: 'config',
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,12 @@ export class ChangeExtractorService {
|
||||||
this.taskChangeComputer = new TaskChangeComputer(logsFinder, boundaryParser);
|
this.taskChangeComputer = new TaskChangeComputer(logsFinder, boundaryParser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readConfigForObservation(teamName: string) {
|
||||||
|
return typeof this.configReader.getConfigSnapshot === 'function'
|
||||||
|
? this.configReader.getConfigSnapshot(teamName)
|
||||||
|
: this.configReader.getConfig(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
setTaskChangePresenceServices(
|
setTaskChangePresenceServices(
|
||||||
repository: TaskChangePresenceRepository,
|
repository: TaskChangePresenceRepository,
|
||||||
tracker: TeamLogSourceTracker
|
tracker: TeamLogSourceTracker
|
||||||
|
|
@ -671,7 +677,7 @@ export class ChangeExtractorService {
|
||||||
try {
|
try {
|
||||||
const [meta, config] = await Promise.all([
|
const [meta, config] = await Promise.all([
|
||||||
this.teamMetaStore.getMeta(teamName).catch(() => null),
|
this.teamMetaStore.getMeta(teamName).catch(() => null),
|
||||||
this.configReader.getConfig(teamName).catch(() => null),
|
this.readConfigForObservation(teamName).catch(() => null),
|
||||||
]);
|
]);
|
||||||
const hasOpenCodeMember = (config?.members ?? []).some(
|
const hasOpenCodeMember = (config?.members ?? []).some(
|
||||||
(member) => member.providerId === 'opencode'
|
(member) => member.providerId === 'opencode'
|
||||||
|
|
@ -996,7 +1002,7 @@ export class ChangeExtractorService {
|
||||||
/** Получить projectPath из конфига команды */
|
/** Получить projectPath из конфига команды */
|
||||||
private async resolveProjectPath(teamName: string): Promise<string | undefined> {
|
private async resolveProjectPath(teamName: string): Promise<string | undefined> {
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await this.readConfigForObservation(teamName);
|
||||||
return config?.projectPath?.trim() || undefined;
|
return config?.projectPath?.trim() || undefined;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { getClaudeBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||||
import { CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, formatCrossTeamText } from '@shared/constants';
|
import { CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, formatCrossTeamText } from '@shared/constants';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import * as fs from 'fs';
|
|
||||||
|
|
||||||
import { buildActionModeAgentBlock } from './actionModeInstructions';
|
import { buildActionModeAgentBlock } from './actionModeInstructions';
|
||||||
import { CascadeGuard } from './CascadeGuard';
|
import { CascadeGuard } from './CascadeGuard';
|
||||||
|
|
@ -117,8 +116,11 @@ export class CrossTeamService {
|
||||||
throw new Error(`Target team not found: ${toTeam}`);
|
throw new Error(`Target team not found: ${toTeam}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Resolve lead
|
// 2. Resolve lead. Reuse the verified target config before falling back to meta storage.
|
||||||
const leadName = (await this.dataService.getLeadMemberName(toTeam)) ?? 'team-lead';
|
const leadName =
|
||||||
|
targetConfig.members?.find((m) => isLeadMember(m))?.name?.trim() ||
|
||||||
|
(await this.dataService.getLeadMemberName(toTeam)) ||
|
||||||
|
'team-lead';
|
||||||
|
|
||||||
// 3. Format
|
// 3. Format
|
||||||
const from = `${fromTeam}.${fromMember}`;
|
const from = `${fromTeam}.${fromMember}`;
|
||||||
|
|
@ -203,39 +205,34 @@ export class CrossTeamService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async listAvailableTargets(excludeTeam?: string): Promise<CrossTeamTarget[]> {
|
async listAvailableTargets(excludeTeam?: string): Promise<CrossTeamTarget[]> {
|
||||||
const teamsDir = getTeamsBasePath();
|
let teams: Awaited<ReturnType<TeamDataService['listTeams']>>;
|
||||||
let entries: string[];
|
|
||||||
try {
|
try {
|
||||||
entries = await fs.promises.readdir(teamsDir);
|
teams = await this.dataService.listTeams();
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const targets: CrossTeamTarget[] = [];
|
const targets: CrossTeamTarget[] = teams
|
||||||
for (const entry of entries) {
|
.filter((team) => {
|
||||||
if (excludeTeam && entry === excludeTeam) continue;
|
if (excludeTeam && team.teamName === excludeTeam) return false;
|
||||||
if (!TEAM_NAME_PATTERN.test(entry)) continue;
|
if (!TEAM_NAME_PATTERN.test(team.teamName)) return false;
|
||||||
|
return !team.deletedAt && !team.pendingCreate;
|
||||||
let config: TeamConfig | null;
|
})
|
||||||
try {
|
.map((team) => {
|
||||||
config = await this.configReader.getConfig(entry);
|
const summaryLead =
|
||||||
} catch {
|
team.leadName || team.leadColor
|
||||||
continue;
|
? { name: team.leadName, color: team.leadColor }
|
||||||
}
|
: team.members?.find((member) => isLeadMember(member));
|
||||||
if (!config || config.deletedAt) continue;
|
return {
|
||||||
|
teamName: team.teamName,
|
||||||
const lead = config.members?.find((m) => isLeadMember(m));
|
displayName: team.displayName || team.teamName,
|
||||||
|
description: team.description,
|
||||||
targets.push({
|
color: team.color,
|
||||||
teamName: entry,
|
...(summaryLead?.name ? { leadName: summaryLead.name } : {}),
|
||||||
displayName: config.name || entry,
|
...(summaryLead?.color ? { leadColor: summaryLead.color } : {}),
|
||||||
description: config.description,
|
isOnline: this.provisioning?.isTeamAlive(team.teamName) ?? false,
|
||||||
color: config.color,
|
};
|
||||||
leadName: lead?.name,
|
|
||||||
leadColor: lead?.color,
|
|
||||||
isOnline: this.provisioning?.isTeamAlive(entry) ?? false,
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return targets.sort((a, b) => {
|
return targets.sort((a, b) => {
|
||||||
if (a.isOnline && !b.isOnline) return -1;
|
if (a.isOnline && !b.isOnline) return -1;
|
||||||
|
|
|
||||||
366
src/main/services/team/LaunchIoGovernor.ts
Normal file
366
src/main/services/team/LaunchIoGovernor.ts
Normal file
|
|
@ -0,0 +1,366 @@
|
||||||
|
import type {
|
||||||
|
GlobalTask,
|
||||||
|
TeamChangeEvent,
|
||||||
|
TeamProvisioningProgress,
|
||||||
|
TeamSummary,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
|
export type LaunchIoGovernorOperationKey = 'teams:list' | 'teams:getAllTasks';
|
||||||
|
|
||||||
|
type GovernedPayload = TeamSummary[] | GlobalTask[];
|
||||||
|
type CloneFn<T> = (value: T) => T;
|
||||||
|
|
||||||
|
interface LaunchIoGovernorLogger {
|
||||||
|
debug?: (message: string) => void;
|
||||||
|
warn?: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LaunchIoGovernorOptions {
|
||||||
|
quietWindowMs?: number;
|
||||||
|
maxStaleAgeMs?: number;
|
||||||
|
stuckLaunchPressureMs?: number;
|
||||||
|
warningCooldownMs?: number;
|
||||||
|
now?: () => number;
|
||||||
|
logger?: LaunchIoGovernorLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveLaunch {
|
||||||
|
teamName: string;
|
||||||
|
source: string;
|
||||||
|
startedAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedValue<T> {
|
||||||
|
value: T;
|
||||||
|
cachedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OperationState<T> {
|
||||||
|
key: LaunchIoGovernorOperationKey;
|
||||||
|
cache: CachedValue<T> | null;
|
||||||
|
dirty: boolean;
|
||||||
|
generation: number;
|
||||||
|
inFlight: Promise<T> | null;
|
||||||
|
loadFresh: (() => Promise<T>) | null;
|
||||||
|
clone: CloneFn<T> | null;
|
||||||
|
scheduledRefresh: ReturnType<typeof setTimeout> | null;
|
||||||
|
lastWarningAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_LAUNCH_IO_QUIET_WINDOW_MS = 3_000;
|
||||||
|
export const DEFAULT_LAUNCH_IO_MAX_STALE_AGE_MS = 15_000;
|
||||||
|
export const DEFAULT_LAUNCH_IO_STUCK_PRESSURE_MS = 10 * 60_000;
|
||||||
|
const DEFAULT_WARNING_COOLDOWN_MS = 10_000;
|
||||||
|
|
||||||
|
const TERMINAL_PROVISIONING_STATES = new Set(['ready', 'failed', 'cancelled', 'disconnected']);
|
||||||
|
|
||||||
|
export function cloneLaunchIoGovernorPayload<T extends GovernedPayload>(value: T): T {
|
||||||
|
if (typeof structuredClone === 'function') {
|
||||||
|
return structuredClone(value);
|
||||||
|
}
|
||||||
|
return JSON.parse(JSON.stringify(value)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LaunchIoGovernor {
|
||||||
|
private readonly quietWindowMs: number;
|
||||||
|
private readonly maxStaleAgeMs: number;
|
||||||
|
private readonly stuckLaunchPressureMs: number;
|
||||||
|
private readonly warningCooldownMs: number;
|
||||||
|
private readonly now: () => number;
|
||||||
|
private readonly logger: LaunchIoGovernorLogger;
|
||||||
|
private readonly activeLaunches = new Map<string, ActiveLaunch>();
|
||||||
|
private readonly operations = new Map<
|
||||||
|
LaunchIoGovernorOperationKey,
|
||||||
|
OperationState<GovernedPayload>
|
||||||
|
>();
|
||||||
|
private quietUntil = 0;
|
||||||
|
|
||||||
|
constructor(options: LaunchIoGovernorOptions = {}) {
|
||||||
|
this.quietWindowMs = options.quietWindowMs ?? DEFAULT_LAUNCH_IO_QUIET_WINDOW_MS;
|
||||||
|
this.maxStaleAgeMs = options.maxStaleAgeMs ?? DEFAULT_LAUNCH_IO_MAX_STALE_AGE_MS;
|
||||||
|
this.stuckLaunchPressureMs =
|
||||||
|
options.stuckLaunchPressureMs ?? DEFAULT_LAUNCH_IO_STUCK_PRESSURE_MS;
|
||||||
|
this.warningCooldownMs = options.warningCooldownMs ?? DEFAULT_WARNING_COOLDOWN_MS;
|
||||||
|
this.now = options.now ?? (() => Date.now());
|
||||||
|
this.logger = options.logger ?? {};
|
||||||
|
this.operations.set('teams:list', this.createOperationState('teams:list'));
|
||||||
|
this.operations.set('teams:getAllTasks', this.createOperationState('teams:getAllTasks'));
|
||||||
|
}
|
||||||
|
|
||||||
|
noteLaunchIntent(teamName: string, source = 'unknown'): void {
|
||||||
|
const normalized = this.normalizeTeamName(teamName);
|
||||||
|
if (!normalized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = this.now();
|
||||||
|
this.pruneStuckLaunches(now);
|
||||||
|
this.activeLaunches.set(normalized, {
|
||||||
|
teamName: normalized,
|
||||||
|
source,
|
||||||
|
startedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
this.markDirty('teams:list');
|
||||||
|
this.scheduleDirtyRefreshes(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
noteProvisioningProgress(progress: TeamProvisioningProgress): void {
|
||||||
|
const teamName = this.normalizeTeamName(progress.teamName);
|
||||||
|
if (!teamName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = this.now();
|
||||||
|
this.pruneStuckLaunches(now);
|
||||||
|
this.markDirty('teams:list');
|
||||||
|
|
||||||
|
if (TERMINAL_PROVISIONING_STATES.has(String(progress.state))) {
|
||||||
|
this.activeLaunches.delete(teamName);
|
||||||
|
this.quietUntil = Math.max(this.quietUntil, now + this.quietWindowMs);
|
||||||
|
this.scheduleDirtyRefreshes(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = this.activeLaunches.get(teamName);
|
||||||
|
this.activeLaunches.set(teamName, {
|
||||||
|
teamName,
|
||||||
|
source: existing?.source ?? 'progress',
|
||||||
|
startedAt: existing?.startedAt ?? now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
this.scheduleDirtyRefreshes(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
noteTeamChange(event: TeamChangeEvent): void {
|
||||||
|
if (event.type === 'config') {
|
||||||
|
this.markDirty('teams:list');
|
||||||
|
this.markDirty('teams:getAllTasks');
|
||||||
|
} else if (event.type === 'task') {
|
||||||
|
this.markDirty('teams:getAllTasks');
|
||||||
|
}
|
||||||
|
if (this.hasLaunchPressure(this.now())) {
|
||||||
|
this.scheduleDirtyRefreshes(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runSummaryOperation<T extends GovernedPayload>(
|
||||||
|
key: LaunchIoGovernorOperationKey,
|
||||||
|
loadFresh: () => Promise<T>,
|
||||||
|
options: { clone: CloneFn<T> }
|
||||||
|
): Promise<T> {
|
||||||
|
const state = this.getOperationState<T>(key);
|
||||||
|
state.loadFresh = loadFresh;
|
||||||
|
state.clone = options.clone;
|
||||||
|
|
||||||
|
if (this.canServeStale(state)) {
|
||||||
|
if (state.dirty) {
|
||||||
|
this.scheduleDeferredRefresh(key, state, false);
|
||||||
|
}
|
||||||
|
return options.clone(state.cache!.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.runFresh(key, state, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearForTests(): void {
|
||||||
|
for (const state of this.operations.values()) {
|
||||||
|
if (state.scheduledRefresh) {
|
||||||
|
clearTimeout(state.scheduledRefresh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.activeLaunches.clear();
|
||||||
|
this.quietUntil = 0;
|
||||||
|
this.operations.clear();
|
||||||
|
this.operations.set('teams:list', this.createOperationState('teams:list'));
|
||||||
|
this.operations.set('teams:getAllTasks', this.createOperationState('teams:getAllTasks'));
|
||||||
|
}
|
||||||
|
|
||||||
|
hasLaunchPressureForTests(): boolean {
|
||||||
|
return this.hasLaunchPressure(this.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
private createOperationState<T extends GovernedPayload>(
|
||||||
|
key: LaunchIoGovernorOperationKey
|
||||||
|
): OperationState<T> {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
cache: null,
|
||||||
|
dirty: false,
|
||||||
|
generation: 0,
|
||||||
|
inFlight: null,
|
||||||
|
loadFresh: null,
|
||||||
|
clone: null,
|
||||||
|
scheduledRefresh: null,
|
||||||
|
lastWarningAt: Number.NEGATIVE_INFINITY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOperationState<T extends GovernedPayload>(
|
||||||
|
key: LaunchIoGovernorOperationKey
|
||||||
|
): OperationState<T> {
|
||||||
|
const state = this.operations.get(key);
|
||||||
|
if (!state) {
|
||||||
|
throw new Error(`Unknown launch IO governor operation: ${key}`);
|
||||||
|
}
|
||||||
|
return state as unknown as OperationState<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private canServeStale<T extends GovernedPayload>(state: OperationState<T>): boolean {
|
||||||
|
const now = this.now();
|
||||||
|
if (!this.hasLaunchPressure(now) || !state.cache) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return now - state.cache.cachedAt <= this.maxStaleAgeMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runFresh<T extends GovernedPayload>(
|
||||||
|
key: LaunchIoGovernorOperationKey,
|
||||||
|
state: OperationState<T>,
|
||||||
|
background: boolean
|
||||||
|
): Promise<T> {
|
||||||
|
if (!state.loadFresh || !state.clone) {
|
||||||
|
throw new Error(`Launch IO governor operation ${key} has no loader`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.inFlight) {
|
||||||
|
try {
|
||||||
|
const joined = await state.inFlight;
|
||||||
|
return state.clone(joined);
|
||||||
|
} catch (error) {
|
||||||
|
if (background) {
|
||||||
|
this.warnRefreshFailure(key, state, error);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generationAtStart = state.generation;
|
||||||
|
const loadFresh = state.loadFresh;
|
||||||
|
const clone = state.clone;
|
||||||
|
const promise = loadFresh();
|
||||||
|
state.inFlight = promise;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fresh = await promise;
|
||||||
|
if (state.generation === generationAtStart) {
|
||||||
|
state.cache = {
|
||||||
|
value: clone(fresh),
|
||||||
|
cachedAt: this.now(),
|
||||||
|
};
|
||||||
|
state.dirty = false;
|
||||||
|
}
|
||||||
|
return clone(fresh);
|
||||||
|
} catch (error) {
|
||||||
|
if (background) {
|
||||||
|
this.warnRefreshFailure(key, state, error);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (state.inFlight === promise) {
|
||||||
|
state.inFlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private markDirty(key: LaunchIoGovernorOperationKey): void {
|
||||||
|
const state = this.getOperationState(key);
|
||||||
|
state.dirty = true;
|
||||||
|
state.generation += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleDirtyRefreshes(force: boolean): void {
|
||||||
|
for (const [key, state] of this.operations) {
|
||||||
|
if (state.dirty) {
|
||||||
|
this.scheduleDeferredRefresh(key, state, force);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleDeferredRefresh<T extends GovernedPayload>(
|
||||||
|
key: LaunchIoGovernorOperationKey,
|
||||||
|
state: OperationState<T>,
|
||||||
|
force: boolean
|
||||||
|
): void {
|
||||||
|
if (!state.loadFresh || !state.clone) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.scheduledRefresh) {
|
||||||
|
if (!force) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(state.scheduledRefresh);
|
||||||
|
state.scheduledRefresh = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delayMs = this.getDelayUntilFreshAllowed(this.now());
|
||||||
|
state.scheduledRefresh = setTimeout(() => {
|
||||||
|
state.scheduledRefresh = null;
|
||||||
|
void this.flushOperation(key);
|
||||||
|
}, delayMs);
|
||||||
|
state.scheduledRefresh.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async flushOperation(key: LaunchIoGovernorOperationKey): Promise<void> {
|
||||||
|
const state = this.getOperationState(key);
|
||||||
|
const now = this.now();
|
||||||
|
if (this.hasLaunchPressure(now)) {
|
||||||
|
this.scheduleDeferredRefresh(key, state, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state.dirty || !state.loadFresh || !state.clone) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.runFresh(key, state, true);
|
||||||
|
} catch {
|
||||||
|
// runFresh already emitted a bounded warning. Keep dirty=true so the next
|
||||||
|
// request or quiet-window timer can retry without losing the last-good cache.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDelayUntilFreshAllowed(now: number): number {
|
||||||
|
this.pruneStuckLaunches(now);
|
||||||
|
if (this.activeLaunches.size > 0) {
|
||||||
|
return this.quietWindowMs;
|
||||||
|
}
|
||||||
|
return Math.max(0, this.quietUntil - now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasLaunchPressure(now: number): boolean {
|
||||||
|
this.pruneStuckLaunches(now);
|
||||||
|
return this.activeLaunches.size > 0 || now < this.quietUntil;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneStuckLaunches(now: number): void {
|
||||||
|
for (const [teamName, launch] of this.activeLaunches) {
|
||||||
|
if (now - launch.updatedAt > this.stuckLaunchPressureMs) {
|
||||||
|
this.activeLaunches.delete(teamName);
|
||||||
|
this.logger.warn?.(
|
||||||
|
`[LaunchIoGovernor] launch pressure expired team=${teamName} source=${launch.source} ageMs=${now - launch.startedAt}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private warnRefreshFailure<T extends GovernedPayload>(
|
||||||
|
key: LaunchIoGovernorOperationKey,
|
||||||
|
state: OperationState<T>,
|
||||||
|
error: unknown
|
||||||
|
): void {
|
||||||
|
const now = this.now();
|
||||||
|
if (now - state.lastWarningAt < this.warningCooldownMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.lastWarningAt = now;
|
||||||
|
const ageMs = state.cache ? now - state.cache.cachedAt : null;
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.warn?.(
|
||||||
|
`[LaunchIoGovernor] deferred refresh failed op=${key} ageMs=${ageMs ?? 'none'} dirty=${state.dirty} activeLaunchCount=${this.activeLaunches.size} error=${errorMessage}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeTeamName(teamName: string | undefined | null): string | null {
|
||||||
|
const normalized = teamName?.trim();
|
||||||
|
return normalized && normalized.length > 0 ? normalized : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,7 @@ const PER_TEAM_READ_TIMEOUT_MS = 5_000;
|
||||||
const GET_CONFIG_SLOW_READ_WARN_MS = 500;
|
const GET_CONFIG_SLOW_READ_WARN_MS = 500;
|
||||||
const CONFIG_SNAPSHOT_RECENT_STAT_FAILURE_FALLBACK_MS = 5_000;
|
const CONFIG_SNAPSHOT_RECENT_STAT_FAILURE_FALLBACK_MS = 5_000;
|
||||||
const COARSE_FS_FULL_VERIFY_MS = 1_500;
|
const COARSE_FS_FULL_VERIFY_MS = 1_500;
|
||||||
|
const LIST_TEAMS_CACHE_TTL_MS = 5_000;
|
||||||
const MAX_SESSION_HISTORY_IN_SUMMARY = 2000;
|
const MAX_SESSION_HISTORY_IN_SUMMARY = 2000;
|
||||||
const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200;
|
const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200;
|
||||||
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
|
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
|
||||||
|
|
@ -71,13 +72,32 @@ interface CachedTeamConfig {
|
||||||
fullVerifiedAt: number;
|
fullVerifiedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TeamConfigReadMode = 'verified' | 'snapshot';
|
||||||
|
|
||||||
interface ConfigReadTiming {
|
interface ConfigReadTiming {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
mode: TeamConfigReadMode;
|
||||||
|
configPath: string;
|
||||||
size: number | null;
|
size: number | null;
|
||||||
statMs: number | null;
|
statMs: number | null;
|
||||||
readMs: number | null;
|
readMs: number | null;
|
||||||
parseMs: number | null;
|
parseMs: number | null;
|
||||||
totalMs: number;
|
totalMs: number;
|
||||||
|
likelyCause: string;
|
||||||
|
fingerprintHighResolution: boolean | null;
|
||||||
|
cacheGeneration: number | null;
|
||||||
|
currentGeneration: number;
|
||||||
|
caller: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedTeamList {
|
||||||
|
value: TeamSummary[];
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InFlightTeamList {
|
||||||
|
promise: Promise<TeamSummary[]>;
|
||||||
|
generationAtStart: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeProjectPathCandidate(value: unknown): string | undefined {
|
function normalizeProjectPathCandidate(value: unknown): string | undefined {
|
||||||
|
|
@ -197,6 +217,48 @@ function cloneConfig(config: TeamConfig): TeamConfig {
|
||||||
return structuredClone(config);
|
return structuredClone(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cloneTeamSummaries(teams: readonly TeamSummary[]): TeamSummary[] {
|
||||||
|
return structuredClone([...teams]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyConfigReadTiming(timing: {
|
||||||
|
statMs: number | null;
|
||||||
|
readMs: number | null;
|
||||||
|
parseMs: number | null;
|
||||||
|
}): string {
|
||||||
|
const statMs = timing.statMs ?? 0;
|
||||||
|
const readMs = timing.readMs ?? 0;
|
||||||
|
const parseMs = timing.parseMs ?? 0;
|
||||||
|
if (readMs >= 1_000 && readMs >= statMs * 2 && readMs >= parseMs * 2) {
|
||||||
|
return 'io_read_slow';
|
||||||
|
}
|
||||||
|
if (statMs >= 1_000 && statMs >= readMs * 2 && statMs >= parseMs * 2) {
|
||||||
|
return 'io_stat_slow';
|
||||||
|
}
|
||||||
|
if (parseMs >= 500 && parseMs >= readMs && parseMs >= statMs) {
|
||||||
|
return 'json_parse_slow';
|
||||||
|
}
|
||||||
|
if (statMs + readMs >= 1_000) {
|
||||||
|
return 'filesystem_pressure';
|
||||||
|
}
|
||||||
|
return 'mixed_or_unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureConfigReadCaller(): string | null {
|
||||||
|
const stack = new Error().stack?.split('\n').slice(2) ?? [];
|
||||||
|
const frame = stack.find((line) => {
|
||||||
|
const normalized = line.trim();
|
||||||
|
return (
|
||||||
|
normalized.length > 0 &&
|
||||||
|
!normalized.includes('TeamConfigReader.') &&
|
||||||
|
!normalized.includes('TeamConfigReader.ts') &&
|
||||||
|
!normalized.includes('captureConfigReadCaller') &&
|
||||||
|
!normalized.includes('node:internal')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return frame?.trim().slice(0, 240) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export class TeamConfigReader {
|
export class TeamConfigReader {
|
||||||
private static readonly configCacheByPath = new Map<string, CachedTeamConfig>();
|
private static readonly configCacheByPath = new Map<string, CachedTeamConfig>();
|
||||||
private static readonly configReadInFlightByPath = new Map<string, Promise<TeamConfig | null>>();
|
private static readonly configReadInFlightByPath = new Map<string, Promise<TeamConfig | null>>();
|
||||||
|
|
@ -205,12 +267,18 @@ export class TeamConfigReader {
|
||||||
Promise<InternalTeamConfigFingerprint | null>
|
Promise<InternalTeamConfigFingerprint | null>
|
||||||
>();
|
>();
|
||||||
private static readonly configGenerationByPath = new Map<string, number>();
|
private static readonly configGenerationByPath = new Map<string, number>();
|
||||||
|
private static readonly listTeamsCacheByBasePath = new Map<string, CachedTeamList>();
|
||||||
|
private static readonly listTeamsInFlightByBasePath = new Map<string, InFlightTeamList>();
|
||||||
|
private static listTeamsGeneration = 0;
|
||||||
|
|
||||||
static clearCacheForTests(): void {
|
static clearCacheForTests(): void {
|
||||||
TeamConfigReader.configCacheByPath.clear();
|
TeamConfigReader.configCacheByPath.clear();
|
||||||
TeamConfigReader.configReadInFlightByPath.clear();
|
TeamConfigReader.configReadInFlightByPath.clear();
|
||||||
TeamConfigReader.configStatInFlightByPath.clear();
|
TeamConfigReader.configStatInFlightByPath.clear();
|
||||||
TeamConfigReader.configGenerationByPath.clear();
|
TeamConfigReader.configGenerationByPath.clear();
|
||||||
|
TeamConfigReader.listTeamsCacheByBasePath.clear();
|
||||||
|
TeamConfigReader.listTeamsInFlightByBasePath.clear();
|
||||||
|
TeamConfigReader.listTeamsGeneration = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static invalidateTeam(teamName: string): void {
|
static invalidateTeam(teamName: string): void {
|
||||||
|
|
@ -223,6 +291,17 @@ export class TeamConfigReader {
|
||||||
TeamConfigReader.configReadInFlightByPath.delete(configPath);
|
TeamConfigReader.configReadInFlightByPath.delete(configPath);
|
||||||
TeamConfigReader.configStatInFlightByPath.delete(configPath);
|
TeamConfigReader.configStatInFlightByPath.delete(configPath);
|
||||||
TeamConfigReader.bumpConfigGeneration(configPath);
|
TeamConfigReader.bumpConfigGeneration(configPath);
|
||||||
|
TeamConfigReader.invalidateListTeamsCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
static invalidateListTeamsCache(): void {
|
||||||
|
TeamConfigReader.listTeamsCacheByBasePath.clear();
|
||||||
|
// Do not clear in-flight scans here. Config writes can arrive while a global
|
||||||
|
// team scan is already running; dropping the in-flight entry starts a second
|
||||||
|
// full scan over all teams and amplifies launch-time filesystem pressure.
|
||||||
|
// The generation check below prevents the stale in-flight result from being
|
||||||
|
// cached after invalidation.
|
||||||
|
TeamConfigReader.listTeamsGeneration += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static invalidatePathForGeneration(
|
private static invalidatePathForGeneration(
|
||||||
|
|
@ -245,6 +324,8 @@ export class TeamConfigReader {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||||
const generation = TeamConfigReader.bumpConfigGeneration(configPath);
|
const generation = TeamConfigReader.bumpConfigGeneration(configPath);
|
||||||
|
TeamConfigReader.configReadInFlightByPath.delete(configPath);
|
||||||
|
TeamConfigReader.configStatInFlightByPath.delete(configPath);
|
||||||
let internalFingerprint: InternalTeamConfigFingerprint | null = null;
|
let internalFingerprint: InternalTeamConfigFingerprint | null = null;
|
||||||
if (fingerprint) {
|
if (fingerprint) {
|
||||||
internalFingerprint = {
|
internalFingerprint = {
|
||||||
|
|
@ -259,6 +340,7 @@ export class TeamConfigReader {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
TeamConfigReader.storeConfigCache(configPath, config, internalFingerprint, true, generation);
|
TeamConfigReader.storeConfigCache(configPath, config, internalFingerprint, true, generation);
|
||||||
|
TeamConfigReader.invalidateListTeamsCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -267,6 +349,44 @@ export class TeamConfigReader {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listTeams(): Promise<TeamSummary[]> {
|
async listTeams(): Promise<TeamSummary[]> {
|
||||||
|
const teamsBasePath = getTeamsBasePath();
|
||||||
|
const cached = TeamConfigReader.listTeamsCacheByBasePath.get(teamsBasePath);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
return cloneTeamSummaries(cached.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRequest = TeamConfigReader.listTeamsInFlightByBasePath.get(teamsBasePath);
|
||||||
|
if (
|
||||||
|
existingRequest &&
|
||||||
|
existingRequest.generationAtStart === TeamConfigReader.listTeamsGeneration
|
||||||
|
) {
|
||||||
|
return cloneTeamSummaries(await existingRequest.promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = this.listTeamsUncached(teamsBasePath);
|
||||||
|
const generationAtStart = TeamConfigReader.listTeamsGeneration;
|
||||||
|
TeamConfigReader.listTeamsInFlightByBasePath.set(teamsBasePath, {
|
||||||
|
promise: request,
|
||||||
|
generationAtStart,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const teams = await request;
|
||||||
|
if (TeamConfigReader.listTeamsGeneration === generationAtStart) {
|
||||||
|
TeamConfigReader.listTeamsCacheByBasePath.set(teamsBasePath, {
|
||||||
|
value: cloneTeamSummaries(teams),
|
||||||
|
expiresAt: Date.now() + LIST_TEAMS_CACHE_TTL_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cloneTeamSummaries(teams);
|
||||||
|
} finally {
|
||||||
|
if (TeamConfigReader.listTeamsInFlightByBasePath.get(teamsBasePath)?.promise === request) {
|
||||||
|
TeamConfigReader.listTeamsInFlightByBasePath.delete(teamsBasePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listTeamsUncached(teamsBasePath: string): Promise<TeamSummary[]> {
|
||||||
const worker = getTeamFsWorkerClient();
|
const worker = getTeamFsWorkerClient();
|
||||||
if (worker.isAvailable()) {
|
if (worker.isAvailable()) {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
|
@ -304,7 +424,7 @@ export class TeamConfigReader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const teamsDir = getTeamsBasePath();
|
const teamsDir = teamsBasePath;
|
||||||
|
|
||||||
let entries: fs.Dirent[];
|
let entries: fs.Dirent[];
|
||||||
try {
|
try {
|
||||||
|
|
@ -413,6 +533,21 @@ export class TeamConfigReader {
|
||||||
const expectedTeammateNames = new Set<string>();
|
const expectedTeammateNames = new Set<string>();
|
||||||
const confirmedArtifactNames = new Set<string>();
|
const confirmedArtifactNames = new Set<string>();
|
||||||
let metaMembers: TeamMember[] = [];
|
let metaMembers: TeamMember[] = [];
|
||||||
|
let leadName: string | undefined;
|
||||||
|
let leadColor: string | undefined;
|
||||||
|
|
||||||
|
const captureLeadMember = (m: TeamMember, overwrite = false): void => {
|
||||||
|
if (m.removedAt) return;
|
||||||
|
if (!isLeadMember(m)) return;
|
||||||
|
const name = m.name?.trim();
|
||||||
|
if (name && (overwrite || !leadName)) {
|
||||||
|
leadName = name;
|
||||||
|
}
|
||||||
|
const colorValue = m.color?.trim();
|
||||||
|
if (colorValue && (overwrite || !leadColor)) {
|
||||||
|
leadColor = colorValue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const mergeMember = (m: TeamMember): void => {
|
const mergeMember = (m: TeamMember): void => {
|
||||||
const name = m.name?.trim();
|
const name = m.name?.trim();
|
||||||
|
|
@ -437,6 +572,7 @@ export class TeamConfigReader {
|
||||||
for (const member of metaMembers) {
|
for (const member of metaMembers) {
|
||||||
const name = member.name?.trim();
|
const name = member.name?.trim();
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
|
captureLeadMember(member);
|
||||||
// Summary/memberCount should represent teammates (exclude the lead process).
|
// Summary/memberCount should represent teammates (exclude the lead process).
|
||||||
if (name === 'user' || isLeadMember(member)) continue;
|
if (name === 'user' || isLeadMember(member)) continue;
|
||||||
const key = name.toLowerCase();
|
const key = name.toLowerCase();
|
||||||
|
|
@ -462,6 +598,7 @@ export class TeamConfigReader {
|
||||||
for (const member of config.members) {
|
for (const member of config.members) {
|
||||||
if (member && typeof member.name === 'string') {
|
if (member && typeof member.name === 'string') {
|
||||||
const name = member.name.trim();
|
const name = member.name.trim();
|
||||||
|
captureLeadMember(member, true);
|
||||||
if (name && name !== 'user' && !isLeadMember(member)) {
|
if (name && name !== 'user' && !isLeadMember(member)) {
|
||||||
confirmedArtifactNames.add(name);
|
confirmedArtifactNames.add(name);
|
||||||
}
|
}
|
||||||
|
|
@ -537,6 +674,8 @@ export class TeamConfigReader {
|
||||||
taskCount: 0,
|
taskCount: 0,
|
||||||
lastActivity: null,
|
lastActivity: null,
|
||||||
...(members.length > 0 ? { members } : {}),
|
...(members.length > 0 ? { members } : {}),
|
||||||
|
...(leadName ? { leadName } : {}),
|
||||||
|
...(leadColor ? { leadColor } : {}),
|
||||||
...(color ? { color } : {}),
|
...(color ? { color } : {}),
|
||||||
...(projectPath ? { projectPath } : {}),
|
...(projectPath ? { projectPath } : {}),
|
||||||
...(leadSessionId ? { leadSessionId } : {}),
|
...(leadSessionId ? { leadSessionId } : {}),
|
||||||
|
|
@ -578,11 +717,21 @@ export class TeamConfigReader {
|
||||||
: teamName;
|
: teamName;
|
||||||
|
|
||||||
let memberCount = 0;
|
let memberCount = 0;
|
||||||
|
let leadName: string | undefined;
|
||||||
|
let leadColor: string | undefined;
|
||||||
try {
|
try {
|
||||||
const metaStore = new TeamMembersMetaStore();
|
const members = await this.membersMetaStore.getMembers(teamName);
|
||||||
const members = await metaStore.getMembers(teamName);
|
|
||||||
memberCount = members.filter((member) => {
|
memberCount = members.filter((member) => {
|
||||||
const name = member.name?.trim() ?? '';
|
const name = member.name?.trim() ?? '';
|
||||||
|
if (!member.removedAt && isLeadMember(member)) {
|
||||||
|
if (name) {
|
||||||
|
leadName = name;
|
||||||
|
}
|
||||||
|
const color = member.color?.trim();
|
||||||
|
if (color) {
|
||||||
|
leadColor = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!name || name === 'user' || isLeadMember(member)) {
|
if (!name || name === 'user' || isLeadMember(member)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -601,6 +750,8 @@ export class TeamConfigReader {
|
||||||
lastActivity:
|
lastActivity:
|
||||||
typeof meta.createdAt === 'number' ? new Date(meta.createdAt).toISOString() : null,
|
typeof meta.createdAt === 'number' ? new Date(meta.createdAt).toISOString() : null,
|
||||||
color: typeof meta.color === 'string' ? meta.color : undefined,
|
color: typeof meta.color === 'string' ? meta.color : undefined,
|
||||||
|
...(leadName ? { leadName } : {}),
|
||||||
|
...(leadColor ? { leadColor } : {}),
|
||||||
projectPath: typeof meta.cwd === 'string' ? meta.cwd : undefined,
|
projectPath: typeof meta.cwd === 'string' ? meta.cwd : undefined,
|
||||||
pendingCreate: true,
|
pendingCreate: true,
|
||||||
};
|
};
|
||||||
|
|
@ -621,7 +772,14 @@ export class TeamConfigReader {
|
||||||
}
|
}
|
||||||
|
|
||||||
const generation = TeamConfigReader.getConfigGeneration(configPath);
|
const generation = TeamConfigReader.getConfigGeneration(configPath);
|
||||||
const readPromise = this.readConfigFromDisk(teamName, configPath, null, true, generation);
|
const readPromise = this.readConfigFromDisk(
|
||||||
|
teamName,
|
||||||
|
configPath,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
generation,
|
||||||
|
'verified'
|
||||||
|
);
|
||||||
TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise);
|
TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -700,7 +858,8 @@ export class TeamConfigReader {
|
||||||
configPath,
|
configPath,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
true,
|
true,
|
||||||
generation
|
generation,
|
||||||
|
'snapshot'
|
||||||
);
|
);
|
||||||
TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise);
|
TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise);
|
||||||
try {
|
try {
|
||||||
|
|
@ -842,21 +1001,31 @@ export class TeamConfigReader {
|
||||||
configPath: string,
|
configPath: string,
|
||||||
knownFingerprint: InternalTeamConfigFingerprint | null = null,
|
knownFingerprint: InternalTeamConfigFingerprint | null = null,
|
||||||
updateCache = false,
|
updateCache = false,
|
||||||
cacheGeneration?: number
|
cacheGeneration?: number,
|
||||||
|
mode: TeamConfigReadMode = 'verified'
|
||||||
): Promise<TeamConfig | null> {
|
): Promise<TeamConfig | null> {
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
|
const caller = captureConfigReadCaller();
|
||||||
let size: number | null = null;
|
let size: number | null = null;
|
||||||
let statMs: number | null = null;
|
let statMs: number | null = null;
|
||||||
let readMs: number | null = null;
|
let readMs: number | null = null;
|
||||||
let parseMs: number | null = null;
|
let parseMs: number | null = null;
|
||||||
|
let fingerprintHighResolution: boolean | null = knownFingerprint?.highResolution ?? null;
|
||||||
|
|
||||||
const buildTiming = (): ConfigReadTiming => ({
|
const buildTiming = (): ConfigReadTiming => ({
|
||||||
teamName,
|
teamName,
|
||||||
|
mode,
|
||||||
|
configPath,
|
||||||
size,
|
size,
|
||||||
statMs,
|
statMs,
|
||||||
readMs,
|
readMs,
|
||||||
parseMs,
|
parseMs,
|
||||||
totalMs: Math.round(performance.now() - startedAt),
|
totalMs: Math.round(performance.now() - startedAt),
|
||||||
|
likelyCause: classifyConfigReadTiming({ statMs, readMs, parseMs }),
|
||||||
|
fingerprintHighResolution,
|
||||||
|
cacheGeneration: cacheGeneration ?? null,
|
||||||
|
currentGeneration: TeamConfigReader.getConfigGeneration(configPath),
|
||||||
|
caller,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -865,6 +1034,7 @@ export class TeamConfigReader {
|
||||||
knownFingerprint ?? (await TeamConfigReader.getConfigFingerprint(configPath));
|
knownFingerprint ?? (await TeamConfigReader.getConfigFingerprint(configPath));
|
||||||
statMs = Math.round(performance.now() - statStartedAt);
|
statMs = Math.round(performance.now() - statStartedAt);
|
||||||
size = fingerprint?.numericSize ?? null;
|
size = fingerprint?.numericSize ?? null;
|
||||||
|
fingerprintHighResolution = fingerprint?.highResolution ?? null;
|
||||||
|
|
||||||
// Safety: refuse special files and huge/binary configs
|
// Safety: refuse special files and huge/binary configs
|
||||||
if (!fingerprint?.isFile) {
|
if (!fingerprint?.isFile) {
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ const PROCESS_HEALTH_INTERVAL_MS = 2_000;
|
||||||
const TASK_MAP_YIELD_EVERY = 250;
|
const TASK_MAP_YIELD_EVERY = 250;
|
||||||
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
|
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
|
||||||
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
|
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
|
||||||
const MEMBER_RUNTIME_ADVISORY_SNAPSHOT_BUDGET_MS = 750;
|
const MEMBER_RUNTIME_ADVISORY_SNAPSHOT_BUDGET_MS = 250;
|
||||||
const MIXED_TEAM_LIVE_MUTATION_BLOCK_MESSAGE =
|
const MIXED_TEAM_LIVE_MUTATION_BLOCK_MESSAGE =
|
||||||
'Live roster mutation on a running mixed team is not supported in V1. Stop the team, edit the roster, then relaunch.';
|
'Live roster mutation on a running mixed team is not supported in V1. Stop the team, edit the roster, then relaunch.';
|
||||||
|
|
||||||
|
|
@ -422,6 +422,10 @@ export class TeamDataService {
|
||||||
return readConfigForUiSnapshot(this.configReader, teamName);
|
return readConfigForUiSnapshot(this.configReader, teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private invalidateGlobalTaskProjectionCache(): void {
|
||||||
|
TeamTaskReader.invalidateAllTasksCache();
|
||||||
|
}
|
||||||
|
|
||||||
private getController(teamName: string): AgentTeamsController {
|
private getController(teamName: string): AgentTeamsController {
|
||||||
return this.controllerFactory(teamName);
|
return this.controllerFactory(teamName);
|
||||||
}
|
}
|
||||||
|
|
@ -514,7 +518,7 @@ export class TeamDataService {
|
||||||
request.catch(() => {
|
request.catch(() => {
|
||||||
/* background advisory refresh is best-effort */
|
/* background advisory refresh is best-effort */
|
||||||
});
|
});
|
||||||
logger.warn(
|
logger.debug(
|
||||||
`getTeamData team=${teamName} member runtime advisories exceeded ${MEMBER_RUNTIME_ADVISORY_SNAPSHOT_BUDGET_MS}ms budget; continuing without advisories for this snapshot`
|
`getTeamData team=${teamName} member runtime advisories exceeded ${MEMBER_RUNTIME_ADVISORY_SNAPSHOT_BUDGET_MS}ms budget; continuing without advisories for this snapshot`
|
||||||
);
|
);
|
||||||
return new Map();
|
return new Map();
|
||||||
|
|
@ -865,7 +869,7 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTaskChangePresence(teamName: string): Promise<Record<string, TaskChangePresenceState>> {
|
async getTaskChangePresence(teamName: string): Promise<Record<string, TaskChangePresenceState>> {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await this.readSnapshotConfig(teamName);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new Error(`Team not found: ${teamName}`);
|
throw new Error(`Team not found: ${teamName}`);
|
||||||
}
|
}
|
||||||
|
|
@ -1121,6 +1125,7 @@ export class TeamDataService {
|
||||||
|
|
||||||
const tasksDir = path.join(getTasksBasePath(), teamName);
|
const tasksDir = path.join(getTasksBasePath(), teamName);
|
||||||
await fs.promises.rm(tasksDir, { recursive: true, force: true });
|
await fs.promises.rm(tasksDir, { recursive: true, force: true });
|
||||||
|
TeamTaskReader.invalidateAllTasksCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
|
async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
|
||||||
|
|
@ -1893,7 +1898,7 @@ export class TeamDataService {
|
||||||
|
|
||||||
let projectPath: string | undefined;
|
let projectPath: string | undefined;
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||||
projectPath = config?.projectPath;
|
projectPath = config?.projectPath;
|
||||||
} catch {
|
} catch {
|
||||||
/* best-effort */
|
/* best-effort */
|
||||||
|
|
@ -1915,6 +1920,7 @@ export class TeamDataService {
|
||||||
...(request.promptTaskRefs?.length ? { promptTaskRefs: request.promptTaskRefs } : {}),
|
...(request.promptTaskRefs?.length ? { promptTaskRefs: request.promptTaskRefs } : {}),
|
||||||
...(shouldStart ? { startImmediately: true } : {}),
|
...(shouldStart ? { startImmediately: true } : {}),
|
||||||
}) as TeamTask;
|
}) as TeamTask;
|
||||||
|
this.invalidateGlobalTaskProjectionCache();
|
||||||
|
|
||||||
// Controller's maybeNotifyAssignedOwner skips the lead (owner === lead).
|
// Controller's maybeNotifyAssignedOwner skips the lead (owner === lead).
|
||||||
// For user-created tasks with startImmediately, ensure the lead also gets notified.
|
// For user-created tasks with startImmediately, ensure the lead also gets notified.
|
||||||
|
|
@ -1943,6 +1949,7 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.getController(teamName).tasks.startTask(taskId, 'user');
|
this.getController(teamName).tasks.startTask(taskId, 'user');
|
||||||
|
this.invalidateGlobalTaskProjectionCache();
|
||||||
|
|
||||||
if (task.owner) {
|
if (task.owner) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1995,6 +2002,7 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.getController(teamName).tasks.startTask(taskId, 'user');
|
this.getController(teamName).tasks.startTask(taskId, 'user');
|
||||||
|
this.invalidateGlobalTaskProjectionCache();
|
||||||
|
|
||||||
if (task.owner) {
|
if (task.owner) {
|
||||||
await this.sendUserTaskStartNotification(teamName, task);
|
await this.sendUserTaskStartNotification(teamName, task);
|
||||||
|
|
@ -2050,6 +2058,7 @@ export class TeamDataService {
|
||||||
actor?: string
|
actor?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.getController(teamName).tasks.setTaskStatus(taskId, status, actor);
|
this.getController(teamName).tasks.setTaskStatus(taskId, status, actor);
|
||||||
|
this.invalidateGlobalTaskProjectionCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -2109,10 +2118,12 @@ export class TeamDataService {
|
||||||
|
|
||||||
async softDeleteTask(teamName: string, taskId: string): Promise<void> {
|
async softDeleteTask(teamName: string, taskId: string): Promise<void> {
|
||||||
this.getController(teamName).tasks.softDeleteTask(taskId, 'user');
|
this.getController(teamName).tasks.softDeleteTask(taskId, 'user');
|
||||||
|
this.invalidateGlobalTaskProjectionCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
async restoreTask(teamName: string, taskId: string): Promise<void> {
|
async restoreTask(teamName: string, taskId: string): Promise<void> {
|
||||||
this.getController(teamName).tasks.restoreTask(taskId, 'user');
|
this.getController(teamName).tasks.restoreTask(taskId, 'user');
|
||||||
|
this.invalidateGlobalTaskProjectionCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDeletedTasks(teamName: string): Promise<TeamTask[]> {
|
async getDeletedTasks(teamName: string): Promise<TeamTask[]> {
|
||||||
|
|
@ -2121,6 +2132,7 @@ export class TeamDataService {
|
||||||
|
|
||||||
async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise<void> {
|
async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise<void> {
|
||||||
this.getController(teamName).tasks.setTaskOwner(taskId, owner);
|
this.getController(teamName).tasks.setTaskOwner(taskId, owner);
|
||||||
|
this.invalidateGlobalTaskProjectionCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateTaskFields(
|
async updateTaskFields(
|
||||||
|
|
@ -2129,6 +2141,7 @@ export class TeamDataService {
|
||||||
fields: { subject?: string; description?: string }
|
fields: { subject?: string; description?: string }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.getController(teamName).tasks.updateTaskFields(taskId, fields);
|
this.getController(teamName).tasks.updateTaskFields(taskId, fields);
|
||||||
|
this.invalidateGlobalTaskProjectionCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
async addTaskAttachment(
|
async addTaskAttachment(
|
||||||
|
|
@ -2140,6 +2153,7 @@ export class TeamDataService {
|
||||||
taskId,
|
taskId,
|
||||||
meta as unknown as Record<string, unknown>
|
meta as unknown as Record<string, unknown>
|
||||||
);
|
);
|
||||||
|
this.invalidateGlobalTaskProjectionCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeTaskAttachment(
|
async removeTaskAttachment(
|
||||||
|
|
@ -2148,6 +2162,7 @@ export class TeamDataService {
|
||||||
attachmentId: string
|
attachmentId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.getController(teamName).tasks.removeTaskAttachment(taskId, attachmentId);
|
this.getController(teamName).tasks.removeTaskAttachment(taskId, attachmentId);
|
||||||
|
this.invalidateGlobalTaskProjectionCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
async setTaskNeedsClarification(
|
async setTaskNeedsClarification(
|
||||||
|
|
@ -2156,6 +2171,7 @@ export class TeamDataService {
|
||||||
value: 'lead' | 'user' | null
|
value: 'lead' | 'user' | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.getController(teamName).tasks.setNeedsClarification(taskId, value);
|
this.getController(teamName).tasks.setNeedsClarification(taskId, value);
|
||||||
|
this.invalidateGlobalTaskProjectionCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
async addTaskRelationship(
|
async addTaskRelationship(
|
||||||
|
|
@ -2169,6 +2185,7 @@ export class TeamDataService {
|
||||||
targetId,
|
targetId,
|
||||||
type === 'blockedBy' ? 'blocked-by' : type
|
type === 'blockedBy' ? 'blocked-by' : type
|
||||||
);
|
);
|
||||||
|
this.invalidateGlobalTaskProjectionCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeTaskRelationship(
|
async removeTaskRelationship(
|
||||||
|
|
@ -2182,6 +2199,7 @@ export class TeamDataService {
|
||||||
targetId,
|
targetId,
|
||||||
type === 'blockedBy' ? 'blocked-by' : type
|
type === 'blockedBy' ? 'blocked-by' : type
|
||||||
);
|
);
|
||||||
|
this.invalidateGlobalTaskProjectionCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
async addTaskComment(
|
async addTaskComment(
|
||||||
|
|
@ -2198,6 +2216,7 @@ export class TeamDataService {
|
||||||
attachments,
|
attachments,
|
||||||
taskRefs,
|
taskRefs,
|
||||||
}) as { task?: TeamTask; comment?: TaskComment };
|
}) as { task?: TeamTask; comment?: TaskComment };
|
||||||
|
this.invalidateGlobalTaskProjectionCache();
|
||||||
const comment =
|
const comment =
|
||||||
addResult.comment ??
|
addResult.comment ??
|
||||||
({
|
({
|
||||||
|
|
@ -2218,7 +2237,7 @@ export class TeamDataService {
|
||||||
let enrichedRequest = request;
|
let enrichedRequest = request;
|
||||||
if (!enrichedRequest.leadSessionId) {
|
if (!enrichedRequest.leadSessionId) {
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||||
if (config?.leadSessionId) {
|
if (config?.leadSessionId) {
|
||||||
enrichedRequest = { ...enrichedRequest, leadSessionId: config.leadSessionId };
|
enrichedRequest = { ...enrichedRequest, leadSessionId: config.leadSessionId };
|
||||||
}
|
}
|
||||||
|
|
@ -2291,7 +2310,7 @@ export class TeamDataService {
|
||||||
|
|
||||||
private async resolveLeadName(teamName: string): Promise<string> {
|
private async resolveLeadName(teamName: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||||
return this.resolveLeadNameFromConfig(config);
|
return this.resolveLeadNameFromConfig(config);
|
||||||
} catch {
|
} catch {
|
||||||
return 'team-lead';
|
return 'team-lead';
|
||||||
|
|
@ -2302,7 +2321,7 @@ export class TeamDataService {
|
||||||
teamName: string
|
teamName: string
|
||||||
): Promise<{ leadName: string; leadSessionId?: string }> {
|
): Promise<{ leadName: string; leadSessionId?: string }> {
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||||
return {
|
return {
|
||||||
leadName: this.resolveLeadNameFromConfig(config),
|
leadName: this.resolveLeadNameFromConfig(config),
|
||||||
leadSessionId: config?.leadSessionId,
|
leadSessionId: config?.leadSessionId,
|
||||||
|
|
@ -2619,7 +2638,7 @@ export class TeamDataService {
|
||||||
const recoverPending = options?.recoverPending === true;
|
const recoverPending = options?.recoverPending === true;
|
||||||
let config: TeamConfig | null = null;
|
let config: TeamConfig | null = null;
|
||||||
try {
|
try {
|
||||||
config = await this.configReader.getConfig(teamName);
|
config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -2774,7 +2793,7 @@ export class TeamDataService {
|
||||||
): Promise<SendMessageResult> {
|
): Promise<SendMessageResult> {
|
||||||
let leadSessionId: string | undefined;
|
let leadSessionId: string | undefined;
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||||
leadSessionId = config?.leadSessionId;
|
leadSessionId = config?.leadSessionId;
|
||||||
} catch {
|
} catch {
|
||||||
// non-critical — proceed without sessionId
|
// non-critical — proceed without sessionId
|
||||||
|
|
@ -2807,7 +2826,7 @@ export class TeamDataService {
|
||||||
|
|
||||||
async getLeadMemberName(teamName: string): Promise<string | null> {
|
async getLeadMemberName(teamName: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||||
|
|
||||||
// Check config.json members first (Claude Code-created teams)
|
// Check config.json members first (Claude Code-created teams)
|
||||||
if (config?.members?.length) {
|
if (config?.members?.length) {
|
||||||
|
|
@ -2832,7 +2851,7 @@ export class TeamDataService {
|
||||||
|
|
||||||
async getTeamDisplayName(teamName: string): Promise<string> {
|
async getTeamDisplayName(teamName: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await this.readSnapshotConfig(teamName);
|
||||||
const displayName = config?.name?.trim();
|
const displayName = config?.name?.trim();
|
||||||
return displayName || teamName;
|
return displayName || teamName;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -2845,7 +2864,7 @@ export class TeamDataService {
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await this.readSnapshotConfig(teamName);
|
||||||
const displayName = config?.name?.trim() || teamName;
|
const displayName = config?.name?.trim() || teamName;
|
||||||
const projectPath =
|
const projectPath =
|
||||||
typeof config?.projectPath === 'string' && config.projectPath.trim().length > 0
|
typeof config?.projectPath === 'string' && config.projectPath.trim().length > 0
|
||||||
|
|
@ -2943,6 +2962,7 @@ export class TeamDataService {
|
||||||
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, {
|
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, {
|
||||||
providerBackendId: request.providerBackendId,
|
providerBackendId: request.providerBackendId,
|
||||||
});
|
});
|
||||||
|
TeamConfigReader.invalidateListTeamsCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
async reconcileTeamArtifacts(
|
async reconcileTeamArtifacts(
|
||||||
|
|
|
||||||
|
|
@ -59,16 +59,53 @@ function resolveWorkerPath(): string | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingEntry {
|
interface PendingEntry {
|
||||||
resolve: (v: unknown) => void;
|
resolve: (v: unknown, diag?: Extract<TeamDataWorkerResponse, { ok: true }>['diag']) => void;
|
||||||
reject: (e: Error) => void;
|
reject: (e: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function summarizeWorkerPayload(
|
||||||
|
payload: TeamDataWorkerRequest['payload']
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!payload) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if ('taskId' in payload) {
|
||||||
|
return {
|
||||||
|
teamName: payload.teamName,
|
||||||
|
taskId: payload.taskId,
|
||||||
|
owner: payload.options?.owner,
|
||||||
|
status: payload.options?.status,
|
||||||
|
intervals: Array.isArray(payload.options?.intervals)
|
||||||
|
? payload.options.intervals.length
|
||||||
|
: undefined,
|
||||||
|
since: payload.options?.since,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if ('options' in payload) {
|
||||||
|
return {
|
||||||
|
teamName: payload.teamName,
|
||||||
|
cursor:
|
||||||
|
typeof payload.options.cursor === 'string'
|
||||||
|
? payload.options.cursor.slice(0, 24)
|
||||||
|
: payload.options.cursor,
|
||||||
|
limit: payload.options.limit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if ('teamName' in payload) {
|
||||||
|
return {
|
||||||
|
teamName: payload.teamName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
export class TeamDataWorkerClient {
|
export class TeamDataWorkerClient {
|
||||||
private worker: Worker | null = null;
|
private worker: Worker | null = null;
|
||||||
private readonly workerPath: string | null = resolveWorkerPath();
|
private readonly workerPath: string | null = resolveWorkerPath();
|
||||||
private warnedUnavailable = false;
|
private warnedUnavailable = false;
|
||||||
private pending = new Map<string, PendingEntry>();
|
private pending = new Map<string, PendingEntry>();
|
||||||
private getTeamDataInFlight = new Map<string, Promise<TeamViewSnapshot>>();
|
private getTeamDataInFlight = new Map<string, Promise<TeamViewSnapshot>>();
|
||||||
|
private getMessagesPageInFlight = new Map<string, Promise<MessagesPage>>();
|
||||||
|
|
||||||
private failWorker(worker: Worker, error: Error): void {
|
private failWorker(worker: Worker, error: Error): void {
|
||||||
if (this.worker !== worker) return;
|
if (this.worker !== worker) return;
|
||||||
|
|
@ -104,7 +141,7 @@ export class TeamDataWorkerClient {
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
this.pending.delete(msg.id);
|
this.pending.delete(msg.id);
|
||||||
if (msg.ok) {
|
if (msg.ok) {
|
||||||
entry.resolve(msg.result);
|
entry.resolve(msg.result, msg.diag);
|
||||||
} else {
|
} else {
|
||||||
entry.reject(new Error(msg.error));
|
entry.reject(new Error(msg.error));
|
||||||
}
|
}
|
||||||
|
|
@ -132,22 +169,45 @@ export class TeamDataWorkerClient {
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const worker = this.ensureWorker();
|
const worker = this.ensureWorker();
|
||||||
const id = makeId();
|
const id = makeId();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const pendingAtStart = this.pending.size;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
const timeoutError = new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms`);
|
const timeoutError = new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms`);
|
||||||
|
logger.warn(
|
||||||
|
`worker call timeout op=${op} ms=${Date.now() - startedAt} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify(
|
||||||
|
summarizeWorkerPayload(payload)
|
||||||
|
)}`
|
||||||
|
);
|
||||||
this.failWorker(worker, timeoutError);
|
this.failWorker(worker, timeoutError);
|
||||||
worker.terminate().catch(() => undefined);
|
worker.terminate().catch(() => undefined);
|
||||||
reject(timeoutError);
|
reject(timeoutError);
|
||||||
}, WORKER_CALL_TIMEOUT_MS);
|
}, WORKER_CALL_TIMEOUT_MS);
|
||||||
|
|
||||||
this.pending.set(id, {
|
this.pending.set(id, {
|
||||||
resolve: (value) => {
|
resolve: (value, diag) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
const ms = Date.now() - startedAt;
|
||||||
|
if (ms >= 1500) {
|
||||||
|
logger.warn(
|
||||||
|
`worker call slow op=${op} ms=${ms} workerTotalMs=${String(diag?.totalMs ?? 'unknown')} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify(
|
||||||
|
summarizeWorkerPayload(payload)
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
resolve(value);
|
resolve(value);
|
||||||
},
|
},
|
||||||
reject: (error) => {
|
reject: (error) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
const ms = Date.now() - startedAt;
|
||||||
|
if (ms >= 1500) {
|
||||||
|
logger.warn(
|
||||||
|
`worker call failed slow op=${op} ms=${ms} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify(
|
||||||
|
summarizeWorkerPayload(payload)
|
||||||
|
)} error=${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
reject(error);
|
reject(error);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -156,6 +216,38 @@ export class TeamDataWorkerClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async prewarm(): Promise<void> {
|
||||||
|
if (this.worker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.isAvailable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await this.call('warmup', {});
|
||||||
|
const ms = Date.now() - startedAt;
|
||||||
|
if (ms >= 1500) {
|
||||||
|
logger.warn(`worker prewarm slow ms=${ms}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private postBestEffort(
|
||||||
|
op: TeamDataWorkerRequest['op'],
|
||||||
|
payload: TeamDataWorkerRequest['payload']
|
||||||
|
): void {
|
||||||
|
const worker = this.worker;
|
||||||
|
if (!worker) return;
|
||||||
|
try {
|
||||||
|
worker.postMessage({ id: makeId(), op, payload } as TeamDataWorkerRequest);
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(
|
||||||
|
`worker best-effort post failed op=${op} payload=${JSON.stringify(
|
||||||
|
summarizeWorkerPayload(payload)
|
||||||
|
)} error=${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
|
async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
|
||||||
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
||||||
const existing = this.getTeamDataInFlight.get(teamName);
|
const existing = this.getTeamDataInFlight.get(teamName);
|
||||||
|
|
@ -175,8 +267,23 @@ export class TeamDataWorkerClient {
|
||||||
invalidateTeamConfig(teamName: string): void {
|
invalidateTeamConfig(teamName: string): void {
|
||||||
if (!SAFE_NAME_RE.test(teamName)) return;
|
if (!SAFE_NAME_RE.test(teamName)) return;
|
||||||
this.getTeamDataInFlight.delete(teamName);
|
this.getTeamDataInFlight.delete(teamName);
|
||||||
if (!this.worker) return;
|
this.clearMessagesPageInFlightForTeam(teamName);
|
||||||
void this.call('invalidateTeamConfig', { teamName }).catch(() => undefined);
|
this.postBestEffort('invalidateTeamConfig', { teamName });
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateTeamMessageFeed(teamName: string): void {
|
||||||
|
if (!SAFE_NAME_RE.test(teamName)) return;
|
||||||
|
this.clearMessagesPageInFlightForTeam(teamName);
|
||||||
|
this.postBestEffort('invalidateTeamMessageFeed', { teamName });
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearMessagesPageInFlightForTeam(teamName: string): void {
|
||||||
|
const prefix = `{"teamName":"${teamName}",`;
|
||||||
|
for (const key of this.getMessagesPageInFlight.keys()) {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
this.getMessagesPageInFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMessagesPage(
|
async getMessagesPage(
|
||||||
|
|
@ -184,7 +291,26 @@ export class TeamDataWorkerClient {
|
||||||
options: { cursor?: string | null; limit: number }
|
options: { cursor?: string | null; limit: number }
|
||||||
): Promise<MessagesPage> {
|
): Promise<MessagesPage> {
|
||||||
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
||||||
return this.call('getMessagesPage', { teamName, options }) as Promise<MessagesPage>;
|
const key = JSON.stringify({
|
||||||
|
teamName,
|
||||||
|
cursor: options.cursor ?? null,
|
||||||
|
limit: options.limit,
|
||||||
|
});
|
||||||
|
const existing = this.getMessagesPageInFlight.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const promise = (
|
||||||
|
this.call('getMessagesPage', {
|
||||||
|
teamName,
|
||||||
|
options,
|
||||||
|
}) as Promise<MessagesPage>
|
||||||
|
).finally(() => {
|
||||||
|
if (this.getMessagesPageInFlight.get(key) === promise) {
|
||||||
|
this.getMessagesPageInFlight.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.getMessagesPageInFlight.set(key, promise);
|
||||||
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMemberActivityMeta(teamName: string): Promise<TeamMemberActivityMeta> {
|
async getMemberActivityMeta(teamName: string): Promise<TeamMemberActivityMeta> {
|
||||||
|
|
@ -213,6 +339,7 @@ export class TeamDataWorkerClient {
|
||||||
this.worker?.terminate().catch(() => undefined);
|
this.worker?.terminate().catch(() => undefined);
|
||||||
this.worker = null;
|
this.worker = null;
|
||||||
this.getTeamDataInFlight.clear();
|
this.getTeamDataInFlight.clear();
|
||||||
|
this.getMessagesPageInFlight.clear();
|
||||||
for (const [, entry] of this.pending) {
|
for (const [, entry] of this.pending) {
|
||||||
entry.reject(new Error('Client disposed'));
|
entry.reject(new Error('Client disposed'));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ interface GetAllTasksPayload {
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkerRequest =
|
type WorkerRequest =
|
||||||
|
| { id: string; op: 'warmup'; payload?: Record<string, never> }
|
||||||
| { id: string; op: 'listTeams'; payload: ListTeamsPayload }
|
| { id: string; op: 'listTeams'; payload: ListTeamsPayload }
|
||||||
| { id: string; op: 'getAllTasks'; payload: GetAllTasksPayload };
|
| { id: string; op: 'getAllTasks'; payload: GetAllTasksPayload };
|
||||||
|
|
||||||
|
|
@ -43,6 +44,33 @@ type WorkerResponse =
|
||||||
| { id: string; ok: true; result: unknown; diag?: WorkerDiag }
|
| { id: string; ok: true; result: unknown; diag?: WorkerDiag }
|
||||||
| { id: string; ok: false; error: string };
|
| { id: string; ok: false; error: string };
|
||||||
|
|
||||||
|
function summarizeWorkerPayload(payload: WorkerRequest['payload']): Record<string, unknown> {
|
||||||
|
if (!payload) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if ('teamsDir' in payload) {
|
||||||
|
return {
|
||||||
|
teamsDir: payload.teamsDir,
|
||||||
|
concurrency: payload.concurrency,
|
||||||
|
maxConfigReadMs: payload.maxConfigReadMs,
|
||||||
|
maxConfigBytes: payload.maxConfigBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!('tasksBase' in payload)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tasksBase: payload.tasksBase,
|
||||||
|
concurrency: payload.concurrency,
|
||||||
|
maxTaskReadMs: payload.maxTaskReadMs,
|
||||||
|
maxTaskBytes: payload.maxTaskBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDiagTotalMs(diag: WorkerDiag | undefined): unknown {
|
||||||
|
return diag && typeof diag === 'object' ? diag.totalMs : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function makeId(): string {
|
function makeId(): string {
|
||||||
return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`;
|
return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`;
|
||||||
}
|
}
|
||||||
|
|
@ -87,6 +115,18 @@ export class TeamFsWorkerClient {
|
||||||
{ resolve: (v: { result: unknown; diag?: WorkerDiag }) => void; reject: (e: Error) => void }
|
{ resolve: (v: { result: unknown; diag?: WorkerDiag }) => void; reject: (e: Error) => void }
|
||||||
>();
|
>();
|
||||||
|
|
||||||
|
private failWorker(worker: Worker, error: Error): void {
|
||||||
|
if (this.worker !== worker) return;
|
||||||
|
|
||||||
|
this.worker = null;
|
||||||
|
const pendingEntries = Array.from(this.pending.values());
|
||||||
|
this.pending.clear();
|
||||||
|
|
||||||
|
for (const entry of pendingEntries) {
|
||||||
|
entry.reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
isAvailable(): boolean {
|
isAvailable(): boolean {
|
||||||
if (!this.workerPath && !this.warnedUnavailable && shouldWarnUnavailableWorker()) {
|
if (!this.workerPath && !this.warnedUnavailable && shouldWarnUnavailableWorker()) {
|
||||||
this.warnedUnavailable = true;
|
this.warnedUnavailable = true;
|
||||||
|
|
@ -113,8 +153,9 @@ export class TeamFsWorkerClient {
|
||||||
return this.worker;
|
return this.worker;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.worker = new Worker(this.workerPath);
|
const worker = new Worker(this.workerPath);
|
||||||
this.worker.on('message', (msg: WorkerResponse) => {
|
this.worker = worker;
|
||||||
|
worker.on('message', (msg: WorkerResponse) => {
|
||||||
const entry = this.pending.get(msg.id);
|
const entry = this.pending.get(msg.id);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
this.pending.delete(msg.id);
|
this.pending.delete(msg.id);
|
||||||
|
|
@ -124,26 +165,18 @@ export class TeamFsWorkerClient {
|
||||||
entry.reject(new Error(msg.error));
|
entry.reject(new Error(msg.error));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.worker.on('error', (err) => {
|
worker.on('error', (err) => {
|
||||||
logger.error('Worker error', err);
|
logger.error('Worker error', err);
|
||||||
for (const [, entry] of this.pending) {
|
this.failWorker(worker, err instanceof Error ? err : new Error(String(err)));
|
||||||
entry.reject(err instanceof Error ? err : new Error(String(err)));
|
|
||||||
}
|
|
||||||
this.pending.clear();
|
|
||||||
this.worker = null;
|
|
||||||
});
|
});
|
||||||
this.worker.on('exit', (code) => {
|
worker.on('exit', (code) => {
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
logger.warn(`Worker exited with code ${code}`);
|
logger.warn(`Worker exited with code ${code}`);
|
||||||
}
|
}
|
||||||
for (const [, entry] of this.pending) {
|
this.failWorker(worker, new Error(`Worker exited with code ${code}`));
|
||||||
entry.reject(new Error(`Worker exited with code ${code}`));
|
|
||||||
}
|
|
||||||
this.pending.clear();
|
|
||||||
this.worker = null;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.worker;
|
return worker;
|
||||||
}
|
}
|
||||||
|
|
||||||
private call(
|
private call(
|
||||||
|
|
@ -152,27 +185,51 @@ export class TeamFsWorkerClient {
|
||||||
): Promise<{ result: unknown; diag?: WorkerDiag }> {
|
): Promise<{ result: unknown; diag?: WorkerDiag }> {
|
||||||
const worker = this.ensureWorker();
|
const worker = this.ensureWorker();
|
||||||
const id = makeId();
|
const id = makeId();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const pendingAtStart = this.pending.size;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
this.pending.delete(id);
|
const timeoutError = new Error(
|
||||||
|
`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms (${op})`
|
||||||
|
);
|
||||||
|
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);
|
||||||
try {
|
try {
|
||||||
// Terminate and recreate on next call — worker may be stuck in native IO.
|
// Terminate and recreate on next call - worker may be stuck in native IO.
|
||||||
this.worker?.terminate().catch(() => undefined);
|
worker.terminate().catch(() => undefined);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
|
||||||
this.worker = null;
|
|
||||||
}
|
}
|
||||||
reject(new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms (${op})`));
|
reject(timeoutError);
|
||||||
}, WORKER_CALL_TIMEOUT_MS);
|
}, WORKER_CALL_TIMEOUT_MS);
|
||||||
|
|
||||||
this.pending.set(id, {
|
this.pending.set(id, {
|
||||||
resolve: (value) => {
|
resolve: (value) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
const ms = Date.now() - startedAt;
|
||||||
|
if (ms >= 1500) {
|
||||||
|
logger.warn(
|
||||||
|
`worker call slow op=${op} ms=${ms} workerTotalMs=${String(getDiagTotalMs(value.diag))} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify(
|
||||||
|
summarizeWorkerPayload(payload)
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
resolve(value);
|
resolve(value);
|
||||||
},
|
},
|
||||||
reject: (error) => {
|
reject: (error) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
const ms = Date.now() - startedAt;
|
||||||
|
if (ms >= 1500) {
|
||||||
|
logger.warn(
|
||||||
|
`worker call failed slow op=${op} ms=${ms} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify(
|
||||||
|
summarizeWorkerPayload(payload)
|
||||||
|
)} error=${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
reject(error);
|
reject(error);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -180,6 +237,21 @@ export class TeamFsWorkerClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async prewarm(): Promise<void> {
|
||||||
|
if (this.worker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.isAvailable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await this.call('warmup', {});
|
||||||
|
const ms = Date.now() - startedAt;
|
||||||
|
if (ms >= 1500) {
|
||||||
|
logger.warn(`worker prewarm slow ms=${ms}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async listTeams(options: {
|
async listTeams(options: {
|
||||||
largeConfigBytes: number;
|
largeConfigBytes: number;
|
||||||
configHeadBytes: number;
|
configHeadBytes: number;
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,9 @@ export class TeamMemberLogsFinder {
|
||||||
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
|
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
|
||||||
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
|
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
|
||||||
private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver(
|
private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver(
|
||||||
configReader
|
{
|
||||||
|
getConfig: (teamName) => configReader.getConfigSnapshot(teamName),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ interface RuntimeAdvisoryLogsFinder {
|
||||||
const LOOKBACK_MS = 10 * 60 * 1000;
|
const LOOKBACK_MS = 10 * 60 * 1000;
|
||||||
const CACHE_TTL_MS = 30_000;
|
const CACHE_TTL_MS = 30_000;
|
||||||
const TAIL_BYTES = 64 * 1024;
|
const TAIL_BYTES = 64 * 1024;
|
||||||
const BATCH_WARN_MS = 200;
|
const BATCH_WARN_MS = 1_000;
|
||||||
const ADVISORY_FETCH_CONCURRENCY = 2;
|
const ADVISORY_FETCH_CONCURRENCY = 2;
|
||||||
const QUOTA_EXHAUSTED_TOKENS = [
|
const QUOTA_EXHAUSTED_TOKENS = [
|
||||||
'exhausted your capacity',
|
'exhausted your capacity',
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@ interface TeamMessageFeedCacheEntry {
|
||||||
cachedAt: number;
|
cachedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InFlightTeamMessageFeed {
|
||||||
|
promise: Promise<TeamNormalizedMessageFeed>;
|
||||||
|
generationAtStart: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TeamNormalizedMessageFeed {
|
export interface TeamNormalizedMessageFeed {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
feedRevision: string;
|
feedRevision: string;
|
||||||
|
|
@ -411,11 +416,14 @@ function toFeedRevision(messages: readonly InboxMessage[]): string {
|
||||||
export class TeamMessageFeedService {
|
export class TeamMessageFeedService {
|
||||||
private readonly cacheByTeam = new Map<string, TeamMessageFeedCacheEntry>();
|
private readonly cacheByTeam = new Map<string, TeamMessageFeedCacheEntry>();
|
||||||
private readonly dirtyTeams = new Set<string>();
|
private readonly dirtyTeams = new Set<string>();
|
||||||
|
private readonly inFlightByTeam = new Map<string, InFlightTeamMessageFeed>();
|
||||||
|
private readonly generationByTeam = new Map<string, number>();
|
||||||
|
|
||||||
constructor(private readonly deps: TeamMessageFeedDeps) {}
|
constructor(private readonly deps: TeamMessageFeedDeps) {}
|
||||||
|
|
||||||
invalidate(teamName: string): void {
|
invalidate(teamName: string): void {
|
||||||
this.dirtyTeams.add(teamName);
|
this.dirtyTeams.add(teamName);
|
||||||
|
this.generationByTeam.set(teamName, this.getGeneration(teamName) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFeed(teamName: string): Promise<TeamNormalizedMessageFeed> {
|
async getFeed(teamName: string): Promise<TeamNormalizedMessageFeed> {
|
||||||
|
|
@ -431,20 +439,65 @@ export class TeamMessageFeedService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingRequest = this.inFlightByTeam.get(teamName);
|
||||||
|
const generationAtStart = this.getGeneration(teamName);
|
||||||
|
if (existingRequest && existingRequest.generationAtStart === generationAtStart) {
|
||||||
|
return existingRequest.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = this.buildFeed(
|
||||||
|
teamName,
|
||||||
|
cached,
|
||||||
|
now,
|
||||||
|
cacheDirty,
|
||||||
|
cacheExpired,
|
||||||
|
generationAtStart
|
||||||
|
).finally(() => {
|
||||||
|
if (this.inFlightByTeam.get(teamName)?.promise === request) {
|
||||||
|
this.inFlightByTeam.delete(teamName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.inFlightByTeam.set(teamName, {
|
||||||
|
promise: request,
|
||||||
|
generationAtStart,
|
||||||
|
});
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getGeneration(teamName: string): number {
|
||||||
|
return this.generationByTeam.get(teamName) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildFeed(
|
||||||
|
teamName: string,
|
||||||
|
cached: TeamMessageFeedCacheEntry | undefined,
|
||||||
|
now: number,
|
||||||
|
cacheDirty: boolean,
|
||||||
|
cacheExpired: boolean,
|
||||||
|
generationAtStart: number
|
||||||
|
): Promise<TeamNormalizedMessageFeed> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const configStartedAt = Date.now();
|
||||||
const config = await this.deps.getConfig(teamName);
|
const config = await this.deps.getConfig(teamName);
|
||||||
|
const configMs = Date.now() - configStartedAt;
|
||||||
if (!config) {
|
if (!config) {
|
||||||
const emptyEntry = { feedRevision: toFeedRevision([]), messages: [], cachedAt: now };
|
const emptyEntry = { feedRevision: toFeedRevision([]), messages: [], cachedAt: now };
|
||||||
this.cacheByTeam.set(teamName, emptyEntry);
|
if (this.getGeneration(teamName) === generationAtStart) {
|
||||||
this.dirtyTeams.delete(teamName);
|
this.cacheByTeam.set(teamName, emptyEntry);
|
||||||
|
this.dirtyTeams.delete(teamName);
|
||||||
|
}
|
||||||
return { teamName, ...emptyEntry };
|
return { teamName, ...emptyEntry };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sourceStartedAt = Date.now();
|
||||||
const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
|
const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
|
||||||
this.deps.getInboxMessages(teamName).catch(() => [] as InboxMessage[]),
|
this.deps.getInboxMessages(teamName).catch(() => [] as InboxMessage[]),
|
||||||
this.deps.getLeadSessionMessages(teamName, config).catch(() => [] as InboxMessage[]),
|
this.deps.getLeadSessionMessages(teamName, config).catch(() => [] as InboxMessage[]),
|
||||||
this.deps.getSentMessages(teamName).catch(() => [] as InboxMessage[]),
|
this.deps.getSentMessages(teamName).catch(() => [] as InboxMessage[]),
|
||||||
]);
|
]);
|
||||||
|
const sourceMs = Date.now() - sourceStartedAt;
|
||||||
|
|
||||||
|
const normalizeStartedAt = Date.now();
|
||||||
const syntheticMessages = buildSyntheticOpenCodeBootstrapMessages(config);
|
const syntheticMessages = buildSyntheticOpenCodeBootstrapMessages(config);
|
||||||
let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages];
|
let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages];
|
||||||
messages = dedupeLeadProcessCopies(messages, leadTexts);
|
messages = dedupeLeadProcessCopies(messages, leadTexts);
|
||||||
|
|
@ -461,6 +514,13 @@ export class TeamMessageFeedService {
|
||||||
});
|
});
|
||||||
|
|
||||||
const feedRevision = toFeedRevision(messages);
|
const feedRevision = toFeedRevision(messages);
|
||||||
|
const normalizeMs = Date.now() - normalizeStartedAt;
|
||||||
|
const totalMs = Date.now() - startedAt;
|
||||||
|
if (totalMs >= 750) {
|
||||||
|
logger.warn(
|
||||||
|
`[${teamName}] message feed build slow totalMs=${totalMs} configMs=${configMs} sourceMs=${sourceMs} normalizeMs=${normalizeMs} inbox=${inboxMessages.length} lead=${leadTexts.length} sent=${sentMessages.length} synthetic=${syntheticMessages.length} cacheDirty=${cacheDirty} cacheExpired=${cacheExpired}`
|
||||||
|
);
|
||||||
|
}
|
||||||
if (cached && !cacheDirty && cacheExpired && cached.feedRevision !== feedRevision) {
|
if (cached && !cacheDirty && cacheExpired && cached.feedRevision !== feedRevision) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[${teamName}] Message feed cache expired without dirty invalidation and recovered newer durable messages`
|
`[${teamName}] Message feed cache expired without dirty invalidation and recovered newer durable messages`
|
||||||
|
|
@ -478,8 +538,10 @@ export class TeamMessageFeedService {
|
||||||
cachedAt: now,
|
cachedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.cacheByTeam.set(teamName, nextEntry);
|
if (this.getGeneration(teamName) === generationAtStart) {
|
||||||
this.dirtyTeams.delete(teamName);
|
this.cacheByTeam.set(teamName, nextEntry);
|
||||||
|
this.dirtyTeams.delete(teamName);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
teamName,
|
teamName,
|
||||||
feedRevision: nextEntry.feedRevision,
|
feedRevision: nextEntry.feedRevision,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -21,13 +21,18 @@ import type {
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamTaskReader');
|
const logger = createLogger('Service:TeamTaskReader');
|
||||||
const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024;
|
const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024;
|
||||||
const ALL_TASKS_CACHE_TTL_MS = 500;
|
const ALL_TASKS_CACHE_TTL_MS = 5_000;
|
||||||
|
|
||||||
interface CachedAllTasks {
|
interface CachedAllTasks {
|
||||||
value: (TeamTask & { teamName: string })[];
|
value: (TeamTask & { teamName: string })[];
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InFlightAllTasks {
|
||||||
|
promise: Promise<(TeamTask & { teamName: string })[]>;
|
||||||
|
generationAtStart: number;
|
||||||
|
}
|
||||||
|
|
||||||
function cloneTasks<T>(tasks: T[]): T[] {
|
function cloneTasks<T>(tasks: T[]): T[] {
|
||||||
return structuredClone(tasks);
|
return structuredClone(tasks);
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +79,13 @@ function normalizeTaskRefs(value: unknown): TaskRef[] | undefined {
|
||||||
|
|
||||||
export class TeamTaskReader {
|
export class TeamTaskReader {
|
||||||
private static allTasksCache: CachedAllTasks | null = null;
|
private static allTasksCache: CachedAllTasks | null = null;
|
||||||
private static allTasksInFlight: Promise<(TeamTask & { teamName: string })[]> | null = null;
|
private static allTasksInFlight: InFlightAllTasks | null = null;
|
||||||
|
private static allTasksGeneration = 0;
|
||||||
|
|
||||||
|
static invalidateAllTasksCache(): void {
|
||||||
|
TeamTaskReader.allTasksCache = null;
|
||||||
|
TeamTaskReader.allTasksGeneration += 1;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the next available numeric task ID by scanning ALL task files
|
* Returns the next available numeric task ID by scanning ALL task files
|
||||||
|
|
@ -446,26 +457,55 @@ export class TeamTaskReader {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllTasks(): Promise<(TeamTask & { teamName: string })[]> {
|
async getAllTasks(): Promise<(TeamTask & { teamName: string })[]> {
|
||||||
|
const startedAt = Date.now();
|
||||||
const cached = TeamTaskReader.allTasksCache;
|
const cached = TeamTaskReader.allTasksCache;
|
||||||
if (cached && cached.expiresAt > Date.now()) {
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
return cloneTasks(cached.value);
|
const cloned = cloneTasks(cached.value);
|
||||||
|
const ms = Date.now() - startedAt;
|
||||||
|
if (ms >= 1500) {
|
||||||
|
logger.warn(`[getAllTasks] cache clone slow ms=${ms} tasks=${cloned.length}`);
|
||||||
|
}
|
||||||
|
return cloned;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (TeamTaskReader.allTasksInFlight) {
|
if (
|
||||||
return cloneTasks(await TeamTaskReader.allTasksInFlight);
|
TeamTaskReader.allTasksInFlight &&
|
||||||
|
TeamTaskReader.allTasksInFlight.generationAtStart === TeamTaskReader.allTasksGeneration
|
||||||
|
) {
|
||||||
|
const waitedAt = Date.now();
|
||||||
|
const tasks = await TeamTaskReader.allTasksInFlight.promise;
|
||||||
|
const cloned = cloneTasks(tasks);
|
||||||
|
const ms = Date.now() - startedAt;
|
||||||
|
if (ms >= 1500) {
|
||||||
|
logger.warn(
|
||||||
|
`[getAllTasks] in-flight wait slow ms=${ms} waitMs=${Date.now() - waitedAt} tasks=${cloned.length}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cloned;
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = this.readAllTasksUncached();
|
const request = this.readAllTasksUncached();
|
||||||
TeamTaskReader.allTasksInFlight = request;
|
const generationAtStart = TeamTaskReader.allTasksGeneration;
|
||||||
|
TeamTaskReader.allTasksInFlight = {
|
||||||
|
promise: request,
|
||||||
|
generationAtStart,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const tasks = await request;
|
const tasks = await request;
|
||||||
TeamTaskReader.allTasksCache = {
|
if (TeamTaskReader.allTasksGeneration === generationAtStart) {
|
||||||
value: cloneTasks(tasks),
|
TeamTaskReader.allTasksCache = {
|
||||||
expiresAt: Date.now() + ALL_TASKS_CACHE_TTL_MS,
|
value: cloneTasks(tasks),
|
||||||
};
|
expiresAt: Date.now() + ALL_TASKS_CACHE_TTL_MS,
|
||||||
return cloneTasks(tasks);
|
};
|
||||||
|
}
|
||||||
|
const cloned = cloneTasks(tasks);
|
||||||
|
const ms = Date.now() - startedAt;
|
||||||
|
if (ms >= 1500) {
|
||||||
|
logger.warn(`[getAllTasks] total slow ms=${ms} tasks=${cloned.length}`);
|
||||||
|
}
|
||||||
|
return cloned;
|
||||||
} finally {
|
} finally {
|
||||||
if (TeamTaskReader.allTasksInFlight === request) {
|
if (TeamTaskReader.allTasksInFlight?.promise === request) {
|
||||||
TeamTaskReader.allTasksInFlight = null;
|
TeamTaskReader.allTasksInFlight = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,11 @@ interface SessionProjectMatch extends ProjectDirCandidate {
|
||||||
matchedSessionId: string;
|
matchedSessionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TeamTranscriptProjectConfigReader {
|
||||||
|
getConfig(teamName: string): Promise<TeamConfig | null>;
|
||||||
|
getConfigSnapshot?: (teamName: string) => Promise<TeamConfig | null>;
|
||||||
|
}
|
||||||
|
|
||||||
type ScannedSessionProjectMatch = Omit<SessionProjectMatch, 'projectPath'> & {
|
type ScannedSessionProjectMatch = Omit<SessionProjectMatch, 'projectPath'> & {
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
};
|
};
|
||||||
|
|
@ -187,9 +192,15 @@ export class TeamTranscriptProjectResolver {
|
||||||
>();
|
>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configReader: Pick<TeamConfigReader, 'getConfig'> = new TeamConfigReader()
|
private readonly configReader: TeamTranscriptProjectConfigReader = new TeamConfigReader()
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private readConfigForObservation(teamName: string): Promise<TeamConfig | null> {
|
||||||
|
return typeof this.configReader.getConfigSnapshot === 'function'
|
||||||
|
? this.configReader.getConfigSnapshot(teamName)
|
||||||
|
: this.configReader.getConfig(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
async getContext(
|
async getContext(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
options?: { forceRefresh?: boolean }
|
options?: { forceRefresh?: boolean }
|
||||||
|
|
@ -203,7 +214,7 @@ export class TeamTranscriptProjectResolver {
|
||||||
return cached.value;
|
return cached.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await this.readConfigForObservation(teamName);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1669,6 +1669,12 @@ export class BoardTaskLogStreamService {
|
||||||
private readonly historicalBoardMcpRawProbe: HistoricalBoardMcpRawProbe = new HistoricalBoardMcpRawProbe()
|
private readonly historicalBoardMcpRawProbe: HistoricalBoardMcpRawProbe = new HistoricalBoardMcpRawProbe()
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private readConfigForObservation(teamName: string) {
|
||||||
|
return typeof this.configReader.getConfigSnapshot === 'function'
|
||||||
|
? this.configReader.getConfigSnapshot(teamName)
|
||||||
|
: this.configReader.getConfig(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
private buildLayoutCacheKey(teamName: string, taskId: string): string {
|
private buildLayoutCacheKey(teamName: string, taskId: string): string {
|
||||||
return `${teamName}::${taskId}`;
|
return `${teamName}::${taskId}`;
|
||||||
}
|
}
|
||||||
|
|
@ -2199,7 +2205,7 @@ export class BoardTaskLogStreamService {
|
||||||
this.taskReader.getTasks(teamName).catch(() => []),
|
this.taskReader.getTasks(teamName).catch(() => []),
|
||||||
this.taskReader.getDeletedTasks(teamName).catch(() => []),
|
this.taskReader.getDeletedTasks(teamName).catch(() => []),
|
||||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
||||||
this.configReader.getConfig(teamName).catch(() => null),
|
this.readConfigForObservation(teamName).catch(() => null),
|
||||||
]);
|
]);
|
||||||
const task = [...activeTasks, ...deletedTasks].find((candidate) => candidate.id === taskId);
|
const task = [...activeTasks, ...deletedTasks].find((candidate) => candidate.id === taskId);
|
||||||
const ownerName = task?.owner?.trim();
|
const ownerName = task?.owner?.trim();
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,12 @@ export class CodexNativeTaskLogStreamSource {
|
||||||
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder()
|
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder()
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private readConfigForObservation(teamName: string) {
|
||||||
|
return typeof this.configReader.getConfigSnapshot === 'function'
|
||||||
|
? this.configReader.getConfigSnapshot(teamName)
|
||||||
|
: this.configReader.getConfig(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
async getTaskLogStream(
|
async getTaskLogStream(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
|
|
@ -163,7 +169,7 @@ export class CodexNativeTaskLogStreamSource {
|
||||||
const normalizedOwner = normalizeMemberName(ownerName);
|
const normalizedOwner = normalizeMemberName(ownerName);
|
||||||
const [metaMembers, config] = await Promise.all([
|
const [metaMembers, config] = await Promise.all([
|
||||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
||||||
this.configReader.getConfig(teamName).catch(() => null),
|
this.readConfigForObservation(teamName).catch(() => null),
|
||||||
]);
|
]);
|
||||||
const member = [...metaMembers, ...(config?.members ?? [])].find(
|
const member = [...metaMembers, ...(config?.members ?? [])].find(
|
||||||
(candidate) => normalizeMemberName(candidate.name) === normalizedOwner
|
(candidate) => normalizeMemberName(candidate.name) === normalizedOwner
|
||||||
|
|
|
||||||
|
|
@ -42,19 +42,33 @@ export interface InvalidateTeamConfigPayload {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InvalidateTeamMessageFeedPayload {
|
||||||
|
teamName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamDataWorkerDiag {
|
||||||
|
op: TeamDataWorkerRequest['op'];
|
||||||
|
teamName?: string;
|
||||||
|
taskId?: string;
|
||||||
|
totalMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Request / Response ──
|
// ── Request / Response ──
|
||||||
|
|
||||||
export type TeamDataWorkerRequest =
|
export type TeamDataWorkerRequest =
|
||||||
|
| { id: string; op: 'warmup'; payload?: Record<string, never> }
|
||||||
| { id: string; op: 'getTeamData'; payload: GetTeamDataPayload }
|
| { id: string; op: 'getTeamData'; payload: GetTeamDataPayload }
|
||||||
| { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload }
|
| { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload }
|
||||||
| { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload }
|
| { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload }
|
||||||
| { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload }
|
| { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload }
|
||||||
| { id: string; op: 'invalidateTeamConfig'; payload: InvalidateTeamConfigPayload };
|
| { id: string; op: 'invalidateTeamConfig'; payload: InvalidateTeamConfigPayload }
|
||||||
|
| { id: string; op: 'invalidateTeamMessageFeed'; payload: InvalidateTeamMessageFeedPayload };
|
||||||
|
|
||||||
export type TeamDataWorkerResponse =
|
export type TeamDataWorkerResponse =
|
||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
ok: true;
|
ok: true;
|
||||||
result: TeamViewSnapshot | MessagesPage | TeamMemberActivityMeta | MemberLogSummary[] | null;
|
result: TeamViewSnapshot | MessagesPage | TeamMemberActivityMeta | MemberLogSummary[] | null;
|
||||||
|
diag?: TeamDataWorkerDiag;
|
||||||
}
|
}
|
||||||
| { id: string; ok: false; error: string };
|
| { id: string; ok: false; error: string };
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,22 @@ function respond(msg: TeamDataWorkerResponse): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
|
parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const buildDiag = (): NonNullable<Extract<TeamDataWorkerResponse, { ok: true }>['diag']> => ({
|
||||||
|
op: msg.op,
|
||||||
|
...(msg.payload && 'teamName' in msg.payload ? { teamName: msg.payload.teamName } : {}),
|
||||||
|
...(msg.payload && 'taskId' in msg.payload ? { taskId: msg.payload.taskId } : {}),
|
||||||
|
totalMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
switch (msg.op) {
|
switch (msg.op) {
|
||||||
|
case 'warmup': {
|
||||||
|
respond({ id: msg.id, ok: true, result: null, diag: buildDiag() });
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'getTeamData': {
|
case 'getTeamData': {
|
||||||
const result = await teamDataService.getTeamData(msg.payload.teamName);
|
const result = await teamDataService.getTeamData(msg.payload.teamName);
|
||||||
respond({ id: msg.id, ok: true, result });
|
respond({ id: msg.id, ok: true, result, diag: buildDiag() });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'getMessagesPage': {
|
case 'getMessagesPage': {
|
||||||
|
|
@ -48,17 +59,23 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
|
||||||
msg.payload.teamName,
|
msg.payload.teamName,
|
||||||
msg.payload.options
|
msg.payload.options
|
||||||
);
|
);
|
||||||
respond({ id: msg.id, ok: true, result });
|
respond({ id: msg.id, ok: true, result, diag: buildDiag() });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'getMemberActivityMeta': {
|
case 'getMemberActivityMeta': {
|
||||||
const result = await teamDataService.getMemberActivityMeta(msg.payload.teamName);
|
const result = await teamDataService.getMemberActivityMeta(msg.payload.teamName);
|
||||||
respond({ id: msg.id, ok: true, result });
|
respond({ id: msg.id, ok: true, result, diag: buildDiag() });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'invalidateTeamConfig': {
|
case 'invalidateTeamConfig': {
|
||||||
TeamConfigReader.invalidateTeam(msg.payload.teamName);
|
TeamConfigReader.invalidateTeam(msg.payload.teamName);
|
||||||
respond({ id: msg.id, ok: true, result: null });
|
teamDataService.invalidateMessageFeed(msg.payload.teamName);
|
||||||
|
respond({ id: msg.id, ok: true, result: null, diag: buildDiag() });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'invalidateTeamMessageFeed': {
|
||||||
|
teamDataService.invalidateMessageFeed(msg.payload.teamName);
|
||||||
|
respond({ id: msg.id, ok: true, result: null, diag: buildDiag() });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'findLogsForTask': {
|
case 'findLogsForTask': {
|
||||||
|
|
@ -95,7 +112,7 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
|
||||||
logsInFlight.set(cacheKey, promise);
|
logsInFlight.set(cacheKey, promise);
|
||||||
}
|
}
|
||||||
const result = await promise;
|
const result = await promise;
|
||||||
respond({ id: msg.id, ok: true, result });
|
respond({ id: msg.id, ok: true, result, diag: buildDiag() });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ interface GetAllTasksPayload {
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkerRequest =
|
type WorkerRequest =
|
||||||
|
| { id: string; op: 'warmup'; payload?: Record<string, never> }
|
||||||
| { id: string; op: 'listTeams'; payload: ListTeamsPayload }
|
| { id: string; op: 'listTeams'; payload: ListTeamsPayload }
|
||||||
| { id: string; op: 'getAllTasks'; payload: GetAllTasksPayload };
|
| { id: string; op: 'getAllTasks'; payload: GetAllTasksPayload };
|
||||||
|
|
||||||
|
|
@ -75,6 +76,10 @@ interface ListTeamsDiag {
|
||||||
skipped: number;
|
skipped: number;
|
||||||
skipReasons: Record<string, number>;
|
skipReasons: Record<string, number>;
|
||||||
slowest: SlowEntry[];
|
slowest: SlowEntry[];
|
||||||
|
cacheHits: number;
|
||||||
|
cacheMisses: number;
|
||||||
|
cacheWriteSkips: number;
|
||||||
|
cacheEvictions: number;
|
||||||
totalMs: number;
|
totalMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,12 +92,19 @@ interface GetAllTasksDiag {
|
||||||
skipped: number;
|
skipped: number;
|
||||||
skipReasons: Record<string, number>;
|
skipReasons: Record<string, number>;
|
||||||
slowestTeams: SlowEntry[];
|
slowestTeams: SlowEntry[];
|
||||||
|
cacheHits: number;
|
||||||
|
cacheMisses: number;
|
||||||
|
cacheWriteSkips: number;
|
||||||
|
cacheEvictions: number;
|
||||||
totalMs: number;
|
totalMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TaskReadDiag {
|
interface TaskReadDiag {
|
||||||
skipped: number;
|
skipped: number;
|
||||||
skipReasons: Record<string, number>;
|
skipReasons: Record<string, number>;
|
||||||
|
cacheHits: number;
|
||||||
|
cacheMisses: number;
|
||||||
|
cacheWriteSkips: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
|
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
|
||||||
|
|
@ -104,6 +116,60 @@ const REVIEW_LIFECYCLE_EVENTS = new Set([
|
||||||
'review_started',
|
'review_started',
|
||||||
]);
|
]);
|
||||||
const REVIEW_RESET_STATUSES = new Set(['in_progress', 'deleted']);
|
const REVIEW_RESET_STATUSES = new Set(['in_progress', 'deleted']);
|
||||||
|
const TEAM_SUMMARY_CACHE_MAX_ENTRIES = 1000;
|
||||||
|
const TASK_FILE_CACHE_MAX_ENTRIES = 10000;
|
||||||
|
const BOOTSTRAP_STATE_FILE = 'bootstrap-state.json';
|
||||||
|
const BOOTSTRAP_JOURNAL_FILE = 'bootstrap-journal.jsonl';
|
||||||
|
|
||||||
|
interface PathFingerprint {
|
||||||
|
exists: boolean;
|
||||||
|
isFile?: boolean;
|
||||||
|
isDirectory?: boolean;
|
||||||
|
highResolution?: boolean;
|
||||||
|
size?: string;
|
||||||
|
mode?: string;
|
||||||
|
dev?: string;
|
||||||
|
ino?: string;
|
||||||
|
mtimeNs?: string;
|
||||||
|
ctimeNs?: string;
|
||||||
|
birthtimeNs?: string;
|
||||||
|
mtimeMs?: number;
|
||||||
|
ctimeMs?: number;
|
||||||
|
birthtimeMs?: number;
|
||||||
|
errorCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamSummaryCacheEntry {
|
||||||
|
fingerprint: string;
|
||||||
|
summary: Record<string, unknown>;
|
||||||
|
teamsDir: string;
|
||||||
|
optionKey: string;
|
||||||
|
lastUsedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CachedTaskReadResult =
|
||||||
|
| { task: Record<string, unknown>; skipReason?: undefined }
|
||||||
|
| { task?: undefined; skipReason: string };
|
||||||
|
|
||||||
|
interface TaskFileCacheEntry {
|
||||||
|
fingerprint: string;
|
||||||
|
result: CachedTaskReadResult;
|
||||||
|
tasksBase: string;
|
||||||
|
lastUsedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamSummaryCache = new Map<string, TeamSummaryCacheEntry>();
|
||||||
|
const taskFileCache = new Map<string, TaskFileCacheEntry>();
|
||||||
|
|
||||||
|
interface TeamSummaryDependencyFingerprint {
|
||||||
|
value: string;
|
||||||
|
cacheSafe: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LaunchStateSummaryRead {
|
||||||
|
summary: ReturnType<typeof choosePreferredLaunchStateSummary> | null;
|
||||||
|
cacheable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Parsed JSON types (loose shapes from disk)
|
// Parsed JSON types (loose shapes from disk)
|
||||||
|
|
@ -272,6 +338,319 @@ function pushSlowest(list: SlowEntry[], entry: SlowEntry, maxLen: number): void
|
||||||
if (list.length > maxLen) list.length = maxLen;
|
if (list.length > maxLen) list.length = maxLen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cloneCached<T>(value: T): T {
|
||||||
|
return typeof structuredClone === 'function'
|
||||||
|
? structuredClone(value)
|
||||||
|
: (JSON.parse(JSON.stringify(value)) as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function statPathFingerprint(filePath: string): Promise<PathFingerprint> {
|
||||||
|
try {
|
||||||
|
const stat = await fs.promises.stat(filePath, { bigint: true });
|
||||||
|
const mtimeNs =
|
||||||
|
typeof (stat as fs.BigIntStats & { mtimeNs?: bigint }).mtimeNs === 'bigint'
|
||||||
|
? (stat as fs.BigIntStats & { mtimeNs: bigint }).mtimeNs
|
||||||
|
: undefined;
|
||||||
|
const ctimeNs =
|
||||||
|
typeof (stat as fs.BigIntStats & { ctimeNs?: bigint }).ctimeNs === 'bigint'
|
||||||
|
? (stat as fs.BigIntStats & { ctimeNs: bigint }).ctimeNs
|
||||||
|
: undefined;
|
||||||
|
const birthtimeNs =
|
||||||
|
typeof (stat as fs.BigIntStats & { birthtimeNs?: bigint }).birthtimeNs === 'bigint'
|
||||||
|
? (stat as fs.BigIntStats & { birthtimeNs: bigint }).birthtimeNs
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
isFile: stat.isFile(),
|
||||||
|
isDirectory: stat.isDirectory(),
|
||||||
|
highResolution: typeof mtimeNs === 'bigint' && typeof ctimeNs === 'bigint',
|
||||||
|
size: stat.size.toString(),
|
||||||
|
mode: stat.mode.toString(),
|
||||||
|
dev: stat.dev.toString(),
|
||||||
|
ino: stat.ino.toString(),
|
||||||
|
mtimeNs: mtimeNs?.toString(),
|
||||||
|
ctimeNs: ctimeNs?.toString(),
|
||||||
|
birthtimeNs: birthtimeNs?.toString(),
|
||||||
|
mtimeMs: Number(stat.mtimeMs),
|
||||||
|
ctimeMs: Number(stat.ctimeMs),
|
||||||
|
birthtimeMs: Number(stat.birthtimeMs),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
exists: false,
|
||||||
|
errorCode:
|
||||||
|
typeof (error as NodeJS.ErrnoException | undefined)?.code === 'string'
|
||||||
|
? (error as NodeJS.ErrnoException).code
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fingerprintToString(value: unknown): string {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCacheSafeFingerprint(fingerprint: PathFingerprint): boolean {
|
||||||
|
if (fingerprint.exists) {
|
||||||
|
return fingerprint.highResolution === true;
|
||||||
|
}
|
||||||
|
return fingerprint.errorCode === 'ENOENT' || fingerprint.errorCode === 'ENOTDIR';
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTeamSummaryOptionKey(payload: ListTeamsPayload): string {
|
||||||
|
return fingerprintToString({
|
||||||
|
largeConfigBytes: payload.largeConfigBytes,
|
||||||
|
configHeadBytes: payload.configHeadBytes,
|
||||||
|
maxConfigBytes: payload.maxConfigBytes,
|
||||||
|
maxConfigReadMs: payload.maxConfigReadMs,
|
||||||
|
maxMembersMetaBytes: payload.maxMembersMetaBytes,
|
||||||
|
maxSessionHistoryInSummary: payload.maxSessionHistoryInSummary,
|
||||||
|
maxProjectPathHistoryInSummary: payload.maxProjectPathHistoryInSummary,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTeamSummaryCacheKey(teamsDir: string, teamName: string, optionKey: string): string {
|
||||||
|
return `${teamsDir}\0${teamName}\0${optionKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canCacheTeamSummary(summary: Record<string, unknown>): boolean {
|
||||||
|
if (summary.teamLaunchState === 'partial_pending') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const pendingKeys = [
|
||||||
|
'pendingCount',
|
||||||
|
'runtimeAlivePendingCount',
|
||||||
|
'shellOnlyPendingCount',
|
||||||
|
'runtimeProcessPendingCount',
|
||||||
|
'runtimeCandidatePendingCount',
|
||||||
|
'noRuntimePendingCount',
|
||||||
|
'permissionPendingCount',
|
||||||
|
];
|
||||||
|
return pendingKeys.every((key) => {
|
||||||
|
const value = summary[key];
|
||||||
|
return typeof value !== 'number' || value <= 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readInboxNamesFingerprint(inboxDir: string): Promise<{
|
||||||
|
dir: PathFingerprint;
|
||||||
|
names: string[];
|
||||||
|
cacheSafe: boolean;
|
||||||
|
}> {
|
||||||
|
const dir = await statPathFingerprint(inboxDir);
|
||||||
|
if (!dir.exists || !dir.isDirectory) {
|
||||||
|
return { dir, names: [], cacheSafe: isCacheSafeFingerprint(dir) };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const entries = await fs.promises.readdir(inboxDir, { withFileTypes: true });
|
||||||
|
const names = entries
|
||||||
|
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.sort();
|
||||||
|
return { dir, names, cacheSafe: isCacheSafeFingerprint(dir) };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
dir: {
|
||||||
|
...dir,
|
||||||
|
errorCode:
|
||||||
|
typeof (error as NodeJS.ErrnoException | undefined)?.code === 'string'
|
||||||
|
? (error as NodeJS.ErrnoException).code
|
||||||
|
: 'READDIR_FAILED',
|
||||||
|
},
|
||||||
|
names: [],
|
||||||
|
cacheSafe: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildTeamSummaryFingerprint(
|
||||||
|
teamsDir: string,
|
||||||
|
teamName: string,
|
||||||
|
optionKey: string
|
||||||
|
): Promise<TeamSummaryDependencyFingerprint> {
|
||||||
|
const teamDir = path.join(teamsDir, teamName);
|
||||||
|
const [
|
||||||
|
config,
|
||||||
|
teamMeta,
|
||||||
|
membersMeta,
|
||||||
|
launchState,
|
||||||
|
launchSummary,
|
||||||
|
bootstrapState,
|
||||||
|
bootstrapJournal,
|
||||||
|
] = await Promise.all([
|
||||||
|
statPathFingerprint(path.join(teamDir, 'config.json')),
|
||||||
|
statPathFingerprint(path.join(teamDir, 'team.meta.json')),
|
||||||
|
statPathFingerprint(path.join(teamDir, 'members.meta.json')),
|
||||||
|
statPathFingerprint(path.join(teamDir, TEAM_LAUNCH_STATE_FILE)),
|
||||||
|
statPathFingerprint(path.join(teamDir, TEAM_LAUNCH_SUMMARY_FILE)),
|
||||||
|
statPathFingerprint(path.join(teamDir, BOOTSTRAP_STATE_FILE)),
|
||||||
|
statPathFingerprint(path.join(teamDir, BOOTSTRAP_JOURNAL_FILE)),
|
||||||
|
]);
|
||||||
|
const inbox = await readInboxNamesFingerprint(path.join(teamDir, 'inboxes'));
|
||||||
|
|
||||||
|
const dependencyFingerprint = {
|
||||||
|
version: 1,
|
||||||
|
optionKey,
|
||||||
|
config,
|
||||||
|
teamMeta,
|
||||||
|
membersMeta,
|
||||||
|
launchState,
|
||||||
|
launchSummary,
|
||||||
|
bootstrapState,
|
||||||
|
bootstrapJournal,
|
||||||
|
inbox,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: fingerprintToString(dependencyFingerprint),
|
||||||
|
cacheSafe:
|
||||||
|
[
|
||||||
|
config,
|
||||||
|
teamMeta,
|
||||||
|
membersMeta,
|
||||||
|
launchState,
|
||||||
|
launchSummary,
|
||||||
|
bootstrapState,
|
||||||
|
bootstrapJournal,
|
||||||
|
].every(isCacheSafeFingerprint) && inbox.cacheSafe,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheTeamSummaryIfStable(
|
||||||
|
cacheKey: string,
|
||||||
|
teamsDir: string,
|
||||||
|
teamName: string,
|
||||||
|
optionKey: string,
|
||||||
|
fingerprintBefore: TeamSummaryDependencyFingerprint,
|
||||||
|
summary: Record<string, unknown>,
|
||||||
|
cacheAllowed: boolean,
|
||||||
|
diag: ListTeamsDiag
|
||||||
|
): Promise<void> {
|
||||||
|
if (!cacheAllowed) {
|
||||||
|
teamSummaryCache.delete(cacheKey);
|
||||||
|
diag.cacheWriteSkips++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!canCacheTeamSummary(summary)) {
|
||||||
|
teamSummaryCache.delete(cacheKey);
|
||||||
|
diag.cacheWriteSkips++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fingerprintBefore.cacheSafe) {
|
||||||
|
diag.cacheWriteSkips++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fingerprintAfter = await buildTeamSummaryFingerprint(teamsDir, teamName, optionKey);
|
||||||
|
if (!fingerprintAfter.cacheSafe || fingerprintAfter.value !== fingerprintBefore.value) {
|
||||||
|
diag.cacheWriteSkips++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
teamSummaryCache.set(cacheKey, {
|
||||||
|
fingerprint: fingerprintAfter.value,
|
||||||
|
summary: cloneCached(summary),
|
||||||
|
teamsDir,
|
||||||
|
optionKey,
|
||||||
|
lastUsedAt: nowMs(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneTeamSummaryCache(
|
||||||
|
teamsDir: string,
|
||||||
|
optionKey: string,
|
||||||
|
liveTeamNames: ReadonlySet<string>,
|
||||||
|
diag: ListTeamsDiag
|
||||||
|
): void {
|
||||||
|
for (const [key, entry] of teamSummaryCache) {
|
||||||
|
if (entry.teamsDir === teamsDir && entry.optionKey === optionKey) {
|
||||||
|
const teamName = key.split('\0')[1] ?? '';
|
||||||
|
if (!liveTeamNames.has(teamName)) {
|
||||||
|
teamSummaryCache.delete(key);
|
||||||
|
diag.cacheEvictions++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (teamSummaryCache.size > TEAM_SUMMARY_CACHE_MAX_ENTRIES) {
|
||||||
|
const oldest = teamSummaryCache.keys().next().value;
|
||||||
|
if (typeof oldest !== 'string') break;
|
||||||
|
teamSummaryCache.delete(oldest);
|
||||||
|
diag.cacheEvictions++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTaskOptionKey(payload: GetAllTasksPayload): string {
|
||||||
|
return fingerprintToString({
|
||||||
|
maxTaskBytes: payload.maxTaskBytes,
|
||||||
|
maxTaskReadMs: payload.maxTaskReadMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTaskCacheKey(
|
||||||
|
tasksBase: string,
|
||||||
|
teamName: string,
|
||||||
|
fileName: string,
|
||||||
|
optionKey: string
|
||||||
|
): string {
|
||||||
|
return `${tasksBase}\0${teamName}\0${fileName}\0${optionKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheTaskReadResultIfStable(
|
||||||
|
cacheKey: string,
|
||||||
|
taskPath: string,
|
||||||
|
tasksBase: string,
|
||||||
|
fingerprintBefore: string,
|
||||||
|
fingerprintBeforeCacheSafe: boolean,
|
||||||
|
result: CachedTaskReadResult,
|
||||||
|
taskDiag: TaskReadDiag
|
||||||
|
): Promise<void> {
|
||||||
|
if (!fingerprintBeforeCacheSafe) {
|
||||||
|
taskDiag.cacheWriteSkips++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const after = await statPathFingerprint(taskPath);
|
||||||
|
if (!isCacheSafeFingerprint(after) || fingerprintToString(after) !== fingerprintBefore) {
|
||||||
|
taskDiag.cacheWriteSkips++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
taskFileCache.set(cacheKey, {
|
||||||
|
fingerprint: fingerprintBefore,
|
||||||
|
result: cloneCached(result),
|
||||||
|
tasksBase,
|
||||||
|
lastUsedAt: nowMs(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCachedTaskReadResult(
|
||||||
|
cached: CachedTaskReadResult,
|
||||||
|
tasks: unknown[],
|
||||||
|
taskDiag: TaskReadDiag
|
||||||
|
): void {
|
||||||
|
if (cached.skipReason) {
|
||||||
|
taskDiag.skipped++;
|
||||||
|
bumpSkipReason(taskDiag.skipReasons, cached.skipReason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tasks.push(cloneCached(cached.task));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneTaskFileCache(
|
||||||
|
tasksBase: string,
|
||||||
|
liveCacheKeys: ReadonlySet<string>,
|
||||||
|
diag: GetAllTasksDiag
|
||||||
|
): void {
|
||||||
|
for (const [key, entry] of taskFileCache) {
|
||||||
|
if (entry.tasksBase === tasksBase && !liveCacheKeys.has(key)) {
|
||||||
|
taskFileCache.delete(key);
|
||||||
|
diag.cacheEvictions++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (taskFileCache.size > TASK_FILE_CACHE_MAX_ENTRIES) {
|
||||||
|
const oldest = taskFileCache.keys().next().value;
|
||||||
|
if (typeof oldest !== 'string') break;
|
||||||
|
taskFileCache.delete(oldest);
|
||||||
|
diag.cacheEvictions++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// listTeams
|
// listTeams
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -340,7 +719,7 @@ function dropCliProvisionerMembers(
|
||||||
async function readLaunchState(
|
async function readLaunchState(
|
||||||
teamsDir: string,
|
teamsDir: string,
|
||||||
teamName: string
|
teamName: string
|
||||||
): Promise<ReturnType<typeof choosePreferredLaunchStateSummary>> {
|
): Promise<LaunchStateSummaryRead> {
|
||||||
const bootstrapSnapshot = await readBootstrapLaunchSnapshot(teamName);
|
const bootstrapSnapshot = await readBootstrapLaunchSnapshot(teamName);
|
||||||
const launchStatePath = path.join(teamsDir, teamName, TEAM_LAUNCH_STATE_FILE);
|
const launchStatePath = path.join(teamsDir, teamName, TEAM_LAUNCH_STATE_FILE);
|
||||||
const launchSummaryPath = path.join(teamsDir, teamName, TEAM_LAUNCH_SUMMARY_FILE);
|
const launchSummaryPath = path.join(teamsDir, teamName, TEAM_LAUNCH_SUMMARY_FILE);
|
||||||
|
|
@ -371,11 +750,24 @@ async function readLaunchState(
|
||||||
})(),
|
})(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return choosePreferredLaunchStateSummary({
|
const summary = choosePreferredLaunchStateSummary({
|
||||||
bootstrapSnapshot,
|
bootstrapSnapshot,
|
||||||
launchSnapshot,
|
launchSnapshot,
|
||||||
launchSummaryProjection,
|
launchSummaryProjection,
|
||||||
});
|
});
|
||||||
|
if (launchSnapshot) {
|
||||||
|
return { summary, cacheable: true };
|
||||||
|
}
|
||||||
|
if (!bootstrapSnapshot) {
|
||||||
|
return { summary, cacheable: true };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
bootstrapSnapshot.launchPhase === 'finished' &&
|
||||||
|
bootstrapSnapshot.teamLaunchState !== 'partial_pending'
|
||||||
|
) {
|
||||||
|
return { summary, cacheable: true };
|
||||||
|
}
|
||||||
|
return { summary, cacheable: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -384,13 +776,14 @@ async function readLaunchState(
|
||||||
*/
|
*/
|
||||||
async function readDraftTeamMeta(
|
async function readDraftTeamMeta(
|
||||||
teamsDir: string,
|
teamsDir: string,
|
||||||
teamName: string
|
teamName: string,
|
||||||
|
options: { maxConfigReadMs: number; maxMembersMetaBytes: number }
|
||||||
): Promise<Record<string, unknown> | null> {
|
): Promise<Record<string, unknown> | null> {
|
||||||
const metaPath = path.join(teamsDir, teamName, 'team.meta.json');
|
const metaPath = path.join(teamsDir, teamName, 'team.meta.json');
|
||||||
try {
|
try {
|
||||||
const stat = await fs.promises.stat(metaPath);
|
const stat = await fs.promises.stat(metaPath);
|
||||||
if (!stat.isFile() || stat.size > 256 * 1024) return null;
|
if (!stat.isFile() || stat.size > 256 * 1024) return null;
|
||||||
const raw = await fs.promises.readFile(metaPath, 'utf8');
|
const raw = await readFileUtf8WithTimeout(metaPath, options.maxConfigReadMs);
|
||||||
const meta = JSON.parse(raw) as Record<string, unknown>;
|
const meta = JSON.parse(raw) as Record<string, unknown>;
|
||||||
if (meta?.version !== 1 || typeof meta?.cwd !== 'string') return null;
|
if (meta?.version !== 1 || typeof meta?.cwd !== 'string') return null;
|
||||||
|
|
||||||
|
|
@ -401,14 +794,29 @@ async function readDraftTeamMeta(
|
||||||
|
|
||||||
// Read members.meta.json for member count
|
// Read members.meta.json for member count
|
||||||
let memberCount = 0;
|
let memberCount = 0;
|
||||||
|
let leadName: string | undefined;
|
||||||
|
let leadColor: string | undefined;
|
||||||
try {
|
try {
|
||||||
const membersPath = path.join(teamsDir, teamName, 'members.meta.json');
|
const membersPath = path.join(teamsDir, teamName, 'members.meta.json');
|
||||||
const membersRaw = await fs.promises.readFile(membersPath, 'utf8');
|
const membersStat = await fs.promises.stat(membersPath);
|
||||||
|
if (!membersStat.isFile() || membersStat.size > options.maxMembersMetaBytes) {
|
||||||
|
throw new Error('members_meta_too_large');
|
||||||
|
}
|
||||||
|
const membersRaw = await readFileUtf8WithTimeout(membersPath, options.maxConfigReadMs);
|
||||||
const membersData = JSON.parse(membersRaw) as { members?: unknown[] };
|
const membersData = JSON.parse(membersRaw) as { members?: unknown[] };
|
||||||
if (Array.isArray(membersData?.members)) {
|
if (Array.isArray(membersData?.members)) {
|
||||||
memberCount = membersData.members.filter((member) => {
|
memberCount = membersData.members.filter((member) => {
|
||||||
if (!isRawMember(member)) return false;
|
if (!isRawMember(member)) return false;
|
||||||
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
||||||
|
if (!member.removedAt && isLeadMember(member)) {
|
||||||
|
if (name) {
|
||||||
|
leadName = name;
|
||||||
|
}
|
||||||
|
const color = typeof member.color === 'string' ? member.color.trim() : '';
|
||||||
|
if (color) {
|
||||||
|
leadColor = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!name || name === 'user' || isLeadMember(member)) return false;
|
if (!name || name === 'user' || isLeadMember(member)) return false;
|
||||||
return !member.removedAt;
|
return !member.removedAt;
|
||||||
}).length;
|
}).length;
|
||||||
|
|
@ -426,6 +834,8 @@ async function readDraftTeamMeta(
|
||||||
lastActivity:
|
lastActivity:
|
||||||
typeof meta.createdAt === 'number' ? new Date(meta.createdAt).toISOString() : null,
|
typeof meta.createdAt === 'number' ? new Date(meta.createdAt).toISOString() : null,
|
||||||
color: typeof meta.color === 'string' ? meta.color : undefined,
|
color: typeof meta.color === 'string' ? meta.color : undefined,
|
||||||
|
...(leadName ? { leadName } : {}),
|
||||||
|
...(leadColor ? { leadColor } : {}),
|
||||||
projectPath: typeof meta.cwd === 'string' ? meta.cwd : undefined,
|
projectPath: typeof meta.cwd === 'string' ? meta.cwd : undefined,
|
||||||
pendingCreate: true,
|
pendingCreate: true,
|
||||||
};
|
};
|
||||||
|
|
@ -447,6 +857,10 @@ async function listTeams(
|
||||||
skipped: 0,
|
skipped: 0,
|
||||||
skipReasons: {},
|
skipReasons: {},
|
||||||
slowest: [],
|
slowest: [],
|
||||||
|
cacheHits: 0,
|
||||||
|
cacheMisses: 0,
|
||||||
|
cacheWriteSkips: 0,
|
||||||
|
cacheEvictions: 0,
|
||||||
totalMs: 0,
|
totalMs: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -460,11 +874,26 @@ async function listTeams(
|
||||||
|
|
||||||
const teamDirs = entries.filter((e) => e.isDirectory());
|
const teamDirs = entries.filter((e) => e.isDirectory());
|
||||||
diag.totalDirs = teamDirs.length;
|
diag.totalDirs = teamDirs.length;
|
||||||
|
const optionKey = makeTeamSummaryOptionKey(payload);
|
||||||
|
const liveTeamNames = new Set(teamDirs.map((entry) => entry.name));
|
||||||
|
|
||||||
const perTeam = await mapLimit(teamDirs, payload.concurrency, async (entry) => {
|
const perTeam = await mapLimit(teamDirs, payload.concurrency, async (entry) => {
|
||||||
const teamName = entry.name;
|
const teamName = entry.name;
|
||||||
const t0 = nowMs();
|
const t0 = nowMs();
|
||||||
const configPath = path.join(payload.teamsDir, teamName, 'config.json');
|
const configPath = path.join(payload.teamsDir, teamName, 'config.json');
|
||||||
|
const cacheKey = makeTeamSummaryCacheKey(payload.teamsDir, teamName, optionKey);
|
||||||
|
const dependencyFingerprint = await buildTeamSummaryFingerprint(
|
||||||
|
payload.teamsDir,
|
||||||
|
teamName,
|
||||||
|
optionKey
|
||||||
|
);
|
||||||
|
const cached = teamSummaryCache.get(cacheKey);
|
||||||
|
if (dependencyFingerprint.cacheSafe && cached?.fingerprint === dependencyFingerprint.value) {
|
||||||
|
cached.lastUsedAt = nowMs();
|
||||||
|
diag.cacheHits++;
|
||||||
|
return cloneCached(cached.summary);
|
||||||
|
}
|
||||||
|
diag.cacheMisses++;
|
||||||
|
|
||||||
const skip = (reason: string): null => {
|
const skip = (reason: string): null => {
|
||||||
diag.skipped++;
|
diag.skipped++;
|
||||||
|
|
@ -477,13 +906,37 @@ async function listTeams(
|
||||||
stat = await fs.promises.stat(configPath);
|
stat = await fs.promises.stat(configPath);
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback: check for draft team (team.meta.json without config.json)
|
// Fallback: check for draft team (team.meta.json without config.json)
|
||||||
const draft = await readDraftTeamMeta(payload.teamsDir, teamName);
|
const draft = await readDraftTeamMeta(payload.teamsDir, teamName, payload);
|
||||||
if (draft) return draft;
|
if (draft) {
|
||||||
|
await cacheTeamSummaryIfStable(
|
||||||
|
cacheKey,
|
||||||
|
payload.teamsDir,
|
||||||
|
teamName,
|
||||||
|
optionKey,
|
||||||
|
dependencyFingerprint,
|
||||||
|
draft,
|
||||||
|
true,
|
||||||
|
diag
|
||||||
|
);
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
return skip('config_stat_failed');
|
return skip('config_stat_failed');
|
||||||
}
|
}
|
||||||
if (!stat.isFile()) {
|
if (!stat.isFile()) {
|
||||||
const draft = await readDraftTeamMeta(payload.teamsDir, teamName);
|
const draft = await readDraftTeamMeta(payload.teamsDir, teamName, payload);
|
||||||
if (draft) return draft;
|
if (draft) {
|
||||||
|
await cacheTeamSummaryIfStable(
|
||||||
|
cacheKey,
|
||||||
|
payload.teamsDir,
|
||||||
|
teamName,
|
||||||
|
optionKey,
|
||||||
|
dependencyFingerprint,
|
||||||
|
draft,
|
||||||
|
true,
|
||||||
|
diag
|
||||||
|
);
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
return skip('config_not_file');
|
return skip('config_not_file');
|
||||||
}
|
}
|
||||||
if (stat.size > payload.maxConfigBytes) return skip('config_too_large');
|
if (stat.size > payload.maxConfigBytes) return skip('config_too_large');
|
||||||
|
|
@ -557,6 +1010,21 @@ async function listTeams(
|
||||||
removedAt?: unknown;
|
removedAt?: unknown;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
let leadProviderId: 'anthropic' | 'codex' | 'gemini' | 'opencode' | undefined;
|
let leadProviderId: 'anthropic' | 'codex' | 'gemini' | 'opencode' | undefined;
|
||||||
|
let leadName: string | undefined;
|
||||||
|
let leadColor: string | undefined;
|
||||||
|
|
||||||
|
const captureLeadMember = (member: RawMember, overwrite = false): void => {
|
||||||
|
if (member.removedAt) return;
|
||||||
|
if (!isLeadMember(member)) return;
|
||||||
|
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
||||||
|
if (name && (overwrite || !leadName)) {
|
||||||
|
leadName = name;
|
||||||
|
}
|
||||||
|
const colorValue = typeof member.color === 'string' ? member.color.trim() : '';
|
||||||
|
if (colorValue && (overwrite || !leadColor)) {
|
||||||
|
leadColor = colorValue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const teamMetaPath = path.join(payload.teamsDir, teamName, 'team.meta.json');
|
const teamMetaPath = path.join(payload.teamsDir, teamName, 'team.meta.json');
|
||||||
|
|
@ -595,6 +1063,7 @@ async function listTeams(
|
||||||
: undefined;
|
: undefined;
|
||||||
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
|
captureLeadMember(member);
|
||||||
if (isLeadMember(member)) continue;
|
if (isLeadMember(member)) continue;
|
||||||
const key = name.toLowerCase();
|
const key = name.toLowerCase();
|
||||||
if (member.removedAt) {
|
if (member.removedAt) {
|
||||||
|
|
@ -623,6 +1092,7 @@ async function listTeams(
|
||||||
for (const member of config.members as unknown[]) {
|
for (const member of config.members as unknown[]) {
|
||||||
if (isRawMember(member)) {
|
if (isRawMember(member)) {
|
||||||
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
||||||
|
captureLeadMember(member, true);
|
||||||
if (name && name !== 'user' && !isLeadMember(member)) {
|
if (name && name !== 'user' && !isLeadMember(member)) {
|
||||||
confirmedArtifactNames.add(name);
|
confirmedArtifactNames.add(name);
|
||||||
}
|
}
|
||||||
|
|
@ -657,32 +1127,28 @@ async function listTeams(
|
||||||
leadProviderId,
|
leadProviderId,
|
||||||
members: metaRuntimeMembers,
|
members: metaRuntimeMembers,
|
||||||
});
|
});
|
||||||
const launchStateSummary =
|
const launchStateRead = await readLaunchState(payload.teamsDir, teamName);
|
||||||
(await readLaunchState(payload.teamsDir, teamName)) ??
|
const fallbackLaunchStateSummary = (): ReturnType<typeof choosePreferredLaunchStateSummary> => {
|
||||||
(() => {
|
if (suppressLegacyLaunchArtifactHeuristic) {
|
||||||
if (suppressLegacyLaunchArtifactHeuristic) {
|
return null;
|
||||||
return null;
|
}
|
||||||
}
|
if (!leadSessionId || expectedTeammateNames.size === 0 || confirmedArtifactNames.size === 0) {
|
||||||
if (
|
return null;
|
||||||
!leadSessionId ||
|
}
|
||||||
expectedTeammateNames.size === 0 ||
|
const missingMembers = Array.from(expectedTeammateNames).filter(
|
||||||
confirmedArtifactNames.size === 0
|
(name) => !confirmedArtifactNames.has(name)
|
||||||
) {
|
);
|
||||||
return null;
|
if (missingMembers.length === 0) {
|
||||||
}
|
return null;
|
||||||
const missingMembers = Array.from(expectedTeammateNames).filter(
|
}
|
||||||
(name) => !confirmedArtifactNames.has(name)
|
return {
|
||||||
);
|
partialLaunchFailure: true as const,
|
||||||
if (missingMembers.length === 0) {
|
expectedMemberCount: expectedTeammateNames.size,
|
||||||
return null;
|
confirmedMemberCount: confirmedArtifactNames.size,
|
||||||
}
|
missingMembers,
|
||||||
return {
|
};
|
||||||
partialLaunchFailure: true as const,
|
};
|
||||||
expectedMemberCount: expectedTeammateNames.size,
|
const launchStateSummary = launchStateRead.summary ?? fallbackLaunchStateSummary();
|
||||||
confirmedMemberCount: confirmedArtifactNames.size,
|
|
||||||
missingMembers,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
const summary = {
|
const summary = {
|
||||||
teamName,
|
teamName,
|
||||||
displayName,
|
displayName,
|
||||||
|
|
@ -691,6 +1157,8 @@ async function listTeams(
|
||||||
taskCount: 0,
|
taskCount: 0,
|
||||||
lastActivity: null,
|
lastActivity: null,
|
||||||
...(coloredMembers.length > 0 ? { members: coloredMembers } : {}),
|
...(coloredMembers.length > 0 ? { members: coloredMembers } : {}),
|
||||||
|
...(leadName ? { leadName } : {}),
|
||||||
|
...(leadColor ? { leadColor } : {}),
|
||||||
...(color ? { color } : {}),
|
...(color ? { color } : {}),
|
||||||
...(projectPath ? { projectPath } : {}),
|
...(projectPath ? { projectPath } : {}),
|
||||||
...(leadSessionId ? { leadSessionId } : {}),
|
...(leadSessionId ? { leadSessionId } : {}),
|
||||||
|
|
@ -704,10 +1172,21 @@ async function listTeams(
|
||||||
if (ms >= 250) {
|
if (ms >= 250) {
|
||||||
pushSlowest(diag.slowest, { teamName, ms }, 10);
|
pushSlowest(diag.slowest, { teamName, ms }, 10);
|
||||||
}
|
}
|
||||||
|
await cacheTeamSummaryIfStable(
|
||||||
|
cacheKey,
|
||||||
|
payload.teamsDir,
|
||||||
|
teamName,
|
||||||
|
optionKey,
|
||||||
|
dependencyFingerprint,
|
||||||
|
summary,
|
||||||
|
launchStateRead.cacheable,
|
||||||
|
diag
|
||||||
|
);
|
||||||
return summary;
|
return summary;
|
||||||
});
|
});
|
||||||
|
|
||||||
const teams = perTeam.filter((t): t is NonNullable<typeof t> => t !== null);
|
const teams = perTeam.filter((t): t is NonNullable<typeof t> => t !== null);
|
||||||
|
pruneTeamSummaryCache(payload.teamsDir, optionKey, liveTeamNames, diag);
|
||||||
diag.returned = teams.length;
|
diag.returned = teams.length;
|
||||||
diag.totalMs = nowMs() - startedAt;
|
diag.totalMs = nowMs() - startedAt;
|
||||||
return { teams, diag };
|
return { teams, diag };
|
||||||
|
|
@ -843,19 +1322,27 @@ async function readTasksDirForTeam(
|
||||||
tasksDir: string,
|
tasksDir: string,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
payload: GetAllTasksPayload
|
payload: GetAllTasksPayload
|
||||||
): Promise<{ tasks: unknown[]; taskDiag: TaskReadDiag }> {
|
): Promise<{ tasks: unknown[]; taskDiag: TaskReadDiag; liveCacheKeys: Set<string> }> {
|
||||||
const taskDiag: TaskReadDiag = { skipped: 0, skipReasons: {} };
|
const taskDiag: TaskReadDiag = {
|
||||||
|
skipped: 0,
|
||||||
|
skipReasons: {},
|
||||||
|
cacheHits: 0,
|
||||||
|
cacheMisses: 0,
|
||||||
|
cacheWriteSkips: 0,
|
||||||
|
};
|
||||||
let entries: string[];
|
let entries: string[];
|
||||||
try {
|
try {
|
||||||
entries = await fs.promises.readdir(tasksDir);
|
entries = await fs.promises.readdir(tasksDir);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
return { tasks: [], taskDiag };
|
return { tasks: [], taskDiag, liveCacheKeys: new Set() };
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tasks: unknown[] = [];
|
const tasks: unknown[] = [];
|
||||||
|
const liveCacheKeys = new Set<string>();
|
||||||
|
const optionKey = makeTaskOptionKey(payload);
|
||||||
for (const file of entries) {
|
for (const file of entries) {
|
||||||
if (
|
if (
|
||||||
!file.endsWith('.json') ||
|
!file.endsWith('.json') ||
|
||||||
|
|
@ -867,25 +1354,61 @@ async function readTasksDirForTeam(
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskPath = path.join(tasksDir, file);
|
const taskPath = path.join(tasksDir, file);
|
||||||
|
const cacheKey = makeTaskCacheKey(payload.tasksBase, teamName, file, optionKey);
|
||||||
|
liveCacheKeys.add(cacheKey);
|
||||||
try {
|
try {
|
||||||
const stat = await fs.promises.stat(taskPath);
|
const pathFingerprint = await statPathFingerprint(taskPath);
|
||||||
if (!stat.isFile() || stat.size > payload.maxTaskBytes) {
|
const taskSize = Number(pathFingerprint.size ?? Number.NaN);
|
||||||
|
if (
|
||||||
|
!pathFingerprint.isFile ||
|
||||||
|
!Number.isFinite(taskSize) ||
|
||||||
|
taskSize > payload.maxTaskBytes
|
||||||
|
) {
|
||||||
taskDiag.skipped++;
|
taskDiag.skipped++;
|
||||||
bumpSkipReason(taskDiag.skipReasons, 'task_not_file_or_large');
|
bumpSkipReason(taskDiag.skipReasons, 'task_not_file_or_large');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const fingerprint = fingerprintToString(pathFingerprint);
|
||||||
|
const fingerprintCacheSafe = isCacheSafeFingerprint(pathFingerprint);
|
||||||
|
const cached = taskFileCache.get(cacheKey);
|
||||||
|
if (fingerprintCacheSafe && cached?.fingerprint === fingerprint) {
|
||||||
|
cached.lastUsedAt = nowMs();
|
||||||
|
taskDiag.cacheHits++;
|
||||||
|
applyCachedTaskReadResult(cached.result, tasks, taskDiag);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
taskDiag.cacheMisses++;
|
||||||
|
|
||||||
|
const stat = await fs.promises.stat(taskPath);
|
||||||
const raw = await readFileUtf8WithTimeout(taskPath, payload.maxTaskReadMs);
|
const raw = await readFileUtf8WithTimeout(taskPath, payload.maxTaskReadMs);
|
||||||
const parsed = JSON.parse(raw) as ParsedTask;
|
const parsed = JSON.parse(raw) as ParsedTask;
|
||||||
const metadata = parsed.metadata;
|
const metadata = parsed.metadata;
|
||||||
if (metadata?._internal === true) {
|
if (metadata?._internal === true) {
|
||||||
taskDiag.skipped++;
|
taskDiag.skipped++;
|
||||||
bumpSkipReason(taskDiag.skipReasons, 'task_internal');
|
bumpSkipReason(taskDiag.skipReasons, 'task_internal');
|
||||||
|
await cacheTaskReadResultIfStable(
|
||||||
|
cacheKey,
|
||||||
|
taskPath,
|
||||||
|
payload.tasksBase,
|
||||||
|
fingerprint,
|
||||||
|
fingerprintCacheSafe,
|
||||||
|
{ skipReason: 'task_internal' },
|
||||||
|
taskDiag
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (parsed.status === 'deleted') {
|
if (parsed.status === 'deleted') {
|
||||||
taskDiag.skipped++;
|
taskDiag.skipped++;
|
||||||
bumpSkipReason(taskDiag.skipReasons, 'task_deleted');
|
bumpSkipReason(taskDiag.skipReasons, 'task_deleted');
|
||||||
|
await cacheTaskReadResultIfStable(
|
||||||
|
cacheKey,
|
||||||
|
taskPath,
|
||||||
|
payload.tasksBase,
|
||||||
|
fingerprint,
|
||||||
|
fingerprintCacheSafe,
|
||||||
|
{ skipReason: 'task_deleted' },
|
||||||
|
taskDiag
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -925,7 +1448,7 @@ async function readTasksDirForTeam(
|
||||||
deriveReviewStateFromEvents(historyEvents) ??
|
deriveReviewStateFromEvents(historyEvents) ??
|
||||||
normalizeFallbackReviewState(parsed.reviewState, status);
|
normalizeFallbackReviewState(parsed.reviewState, status);
|
||||||
|
|
||||||
tasks.push({
|
const task = {
|
||||||
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||||
displayId:
|
displayId:
|
||||||
typeof parsed.displayId === 'string' && parsed.displayId.trim().length > 0
|
typeof parsed.displayId === 'string' && parsed.displayId.trim().length > 0
|
||||||
|
|
@ -981,7 +1504,17 @@ async function readTasksDirForTeam(
|
||||||
? (parsed.sourceMessage as Record<string, unknown>)
|
? (parsed.sourceMessage as Record<string, unknown>)
|
||||||
: undefined,
|
: undefined,
|
||||||
teamName,
|
teamName,
|
||||||
});
|
};
|
||||||
|
tasks.push(task);
|
||||||
|
await cacheTaskReadResultIfStable(
|
||||||
|
cacheKey,
|
||||||
|
taskPath,
|
||||||
|
payload.tasksBase,
|
||||||
|
fingerprint,
|
||||||
|
fingerprintCacheSafe,
|
||||||
|
{ task },
|
||||||
|
taskDiag
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
taskDiag.skipped++;
|
taskDiag.skipped++;
|
||||||
const code = (error as NodeJS.ErrnoException).code;
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
|
@ -992,11 +1525,14 @@ async function readTasksDirForTeam(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { tasks, taskDiag };
|
return { tasks, taskDiag, liveCacheKeys };
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeTaskDiag(target: GetAllTasksDiag, source: TaskReadDiag): void {
|
function mergeTaskDiag(target: GetAllTasksDiag, source: TaskReadDiag): void {
|
||||||
target.skipped += source.skipped;
|
target.skipped += source.skipped;
|
||||||
|
target.cacheHits += source.cacheHits;
|
||||||
|
target.cacheMisses += source.cacheMisses;
|
||||||
|
target.cacheWriteSkips += source.cacheWriteSkips;
|
||||||
for (const [reason, count] of Object.entries(source.skipReasons)) {
|
for (const [reason, count] of Object.entries(source.skipReasons)) {
|
||||||
target.skipReasons[reason] = (target.skipReasons[reason] || 0) + count;
|
target.skipReasons[reason] = (target.skipReasons[reason] || 0) + count;
|
||||||
}
|
}
|
||||||
|
|
@ -1015,6 +1551,10 @@ async function getAllTasks(
|
||||||
skipped: 0,
|
skipped: 0,
|
||||||
skipReasons: {},
|
skipReasons: {},
|
||||||
slowestTeams: [],
|
slowestTeams: [],
|
||||||
|
cacheHits: 0,
|
||||||
|
cacheMisses: 0,
|
||||||
|
cacheWriteSkips: 0,
|
||||||
|
cacheEvictions: 0,
|
||||||
totalMs: 0,
|
totalMs: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1031,13 +1571,21 @@ async function getAllTasks(
|
||||||
|
|
||||||
const dirs = entries.filter((e) => e.isDirectory());
|
const dirs = entries.filter((e) => e.isDirectory());
|
||||||
diag.teamDirs = dirs.length;
|
diag.teamDirs = dirs.length;
|
||||||
|
const liveCacheKeys = new Set<string>();
|
||||||
|
|
||||||
const chunks = await mapLimit(dirs, payload.concurrency, async (entry) => {
|
const chunks = await mapLimit(dirs, payload.concurrency, async (entry) => {
|
||||||
const teamName = entry.name;
|
const teamName = entry.name;
|
||||||
const t0 = nowMs();
|
const t0 = nowMs();
|
||||||
try {
|
try {
|
||||||
const tasksDir = path.join(payload.tasksBase, teamName);
|
const tasksDir = path.join(payload.tasksBase, teamName);
|
||||||
const { tasks, taskDiag } = await readTasksDirForTeam(tasksDir, teamName, payload);
|
const {
|
||||||
|
tasks,
|
||||||
|
taskDiag,
|
||||||
|
liveCacheKeys: teamLiveCacheKeys,
|
||||||
|
} = await readTasksDirForTeam(tasksDir, teamName, payload);
|
||||||
|
for (const key of teamLiveCacheKeys) {
|
||||||
|
liveCacheKeys.add(key);
|
||||||
|
}
|
||||||
mergeTaskDiag(diag, taskDiag);
|
mergeTaskDiag(diag, taskDiag);
|
||||||
const ms = nowMs() - t0;
|
const ms = nowMs() - t0;
|
||||||
if (ms >= 250) {
|
if (ms >= 250) {
|
||||||
|
|
@ -1052,6 +1600,7 @@ async function getAllTasks(
|
||||||
});
|
});
|
||||||
|
|
||||||
const tasks = chunks.flat();
|
const tasks = chunks.flat();
|
||||||
|
pruneTaskFileCache(payload.tasksBase, liveCacheKeys, diag);
|
||||||
diag.returned = tasks.length;
|
diag.returned = tasks.length;
|
||||||
diag.totalMs = nowMs() - startedAt;
|
diag.totalMs = nowMs() - startedAt;
|
||||||
return { tasks, diag };
|
return { tasks, diag };
|
||||||
|
|
@ -1068,6 +1617,19 @@ function post(msg: WorkerResponse): void {
|
||||||
parentPort?.on('message', async (msg: WorkerRequest) => {
|
parentPort?.on('message', async (msg: WorkerRequest) => {
|
||||||
const { id, op } = msg;
|
const { id, op } = msg;
|
||||||
try {
|
try {
|
||||||
|
if (op === 'warmup') {
|
||||||
|
post({
|
||||||
|
id,
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
ready: true,
|
||||||
|
teamSummaryCacheEntries: teamSummaryCache.size,
|
||||||
|
taskFileCacheEntries: taskFileCache.size,
|
||||||
|
},
|
||||||
|
diag: { op, totalMs: 0 },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (op === 'listTeams') {
|
if (op === 'listTeams') {
|
||||||
const { teams, diag } = await listTeams(msg.payload);
|
const { teams, diag } = await listTeams(msg.payload);
|
||||||
post({ id, ok: true, result: teams, diag });
|
post({ id, ok: true, result: teams, diag });
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ export interface TeamSummary {
|
||||||
color?: string;
|
color?: string;
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
members?: TeamSummaryMember[];
|
members?: TeamSummaryMember[];
|
||||||
|
leadName?: string;
|
||||||
|
leadColor?: string;
|
||||||
taskCount: number;
|
taskCount: number;
|
||||||
lastActivity: string | null;
|
lastActivity: string | null;
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV,
|
MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV,
|
||||||
|
|
@ -80,6 +80,34 @@ describe('createMemberWorkSyncFeature composition', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses snapshot config reads for startup roster materialization', async () => {
|
||||||
|
const getConfig = vi.fn(async () => ({ members: [] }));
|
||||||
|
const getConfigSnapshot = vi.fn(async () => ({
|
||||||
|
members: [{ name: 'alice' }],
|
||||||
|
}));
|
||||||
|
const feature = createMemberWorkSyncFeature({
|
||||||
|
teamsBasePath: makeTempRoot(),
|
||||||
|
configReader: {
|
||||||
|
getConfig,
|
||||||
|
getConfigSnapshot,
|
||||||
|
} as never,
|
||||||
|
taskReader: {} as never,
|
||||||
|
kanbanManager: {} as never,
|
||||||
|
membersMetaStore: {
|
||||||
|
getMembers: vi.fn(async () => []),
|
||||||
|
} as never,
|
||||||
|
nudgeSideEffectsEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await feature.enqueueStartupScan(['my-team']);
|
||||||
|
expect(getConfigSnapshot).toHaveBeenCalledWith('my-team');
|
||||||
|
expect(getConfig).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
await feature.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('builds Claude Stop hook settings without requiring nudge side effects', async () => {
|
it('builds Claude Stop hook settings without requiring nudge side effects', async () => {
|
||||||
const root = makeTempRoot();
|
const root = makeTempRoot();
|
||||||
const feature = createMemberWorkSyncFeature({
|
const feature = createMemberWorkSyncFeature({
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,29 @@ const { mockTeamDataWorkerClient } = vi.hoisted(() => ({
|
||||||
getMemberActivityMeta: vi.fn(),
|
getMemberActivityMeta: vi.fn(),
|
||||||
findLogsForTask: vi.fn(),
|
findLogsForTask: vi.fn(),
|
||||||
invalidateTeamConfig: vi.fn(),
|
invalidateTeamConfig: vi.fn(),
|
||||||
|
invalidateTeamMessageFeed: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function createDeferred<T>(): {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
reject: (error: unknown) => void;
|
||||||
|
} {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (error: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks(): Promise<void> {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
vi.mock('@main/services/infrastructure/NotificationManager', () => ({
|
vi.mock('@main/services/infrastructure/NotificationManager', () => ({
|
||||||
NotificationManager: {
|
NotificationManager: {
|
||||||
getInstance: vi.fn().mockReturnValue({
|
getInstance: vi.fn().mockReturnValue({
|
||||||
|
|
@ -136,6 +157,7 @@ import {
|
||||||
removeTeamHandlers,
|
removeTeamHandlers,
|
||||||
} from '../../../src/main/ipc/teams';
|
} from '../../../src/main/ipc/teams';
|
||||||
import { ConfigManager } from '../../../src/main/services/infrastructure/ConfigManager';
|
import { ConfigManager } from '../../../src/main/services/infrastructure/ConfigManager';
|
||||||
|
import { LaunchIoGovernor } from '../../../src/main/services/team/LaunchIoGovernor';
|
||||||
import { getAppDataPath } from '../../../src/main/utils/pathDecoder';
|
import { getAppDataPath } from '../../../src/main/utils/pathDecoder';
|
||||||
|
|
||||||
describe('ipc teams handlers', () => {
|
describe('ipc teams handlers', () => {
|
||||||
|
|
@ -148,6 +170,7 @@ describe('ipc teams handlers', () => {
|
||||||
handlers.delete(channel);
|
handlers.delete(channel);
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
let launchIoGovernor: LaunchIoGovernor;
|
||||||
|
|
||||||
const service = {
|
const service = {
|
||||||
listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]),
|
listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]),
|
||||||
|
|
@ -166,6 +189,7 @@ describe('ipc teams handlers', () => {
|
||||||
feedRevision: 'rev-1',
|
feedRevision: 'rev-1',
|
||||||
messages: [] as InboxMessage[],
|
messages: [] as InboxMessage[],
|
||||||
})),
|
})),
|
||||||
|
getAllTasks: vi.fn(async () => [{ id: 'task-1', teamName: 'my-team', subject: 'Task 1' }]),
|
||||||
getMessagesPage: vi.fn(
|
getMessagesPage: vi.fn(
|
||||||
async (..._args: unknown[]): Promise<MessagesPage> => ({
|
async (..._args: unknown[]): Promise<MessagesPage> => ({
|
||||||
messages: [] as InboxMessage[],
|
messages: [] as InboxMessage[],
|
||||||
|
|
@ -188,6 +212,8 @@ describe('ipc teams handlers', () => {
|
||||||
projectPath: '/tmp/project',
|
projectPath: '/tmp/project',
|
||||||
})),
|
})),
|
||||||
deleteTeam: vi.fn(async () => undefined),
|
deleteTeam: vi.fn(async () => undefined),
|
||||||
|
restoreTeam: vi.fn(async () => undefined),
|
||||||
|
permanentlyDeleteTeam: vi.fn(async () => undefined),
|
||||||
getLeadMemberName: vi.fn(async () => 'team-lead'),
|
getLeadMemberName: vi.fn(async () => 'team-lead'),
|
||||||
getTeamDisplayName: vi.fn(async () => 'My Team'),
|
getTeamDisplayName: vi.fn(async () => 'My Team'),
|
||||||
updateConfig: vi.fn(async () => ({ name: 'My Team' })),
|
updateConfig: vi.fn(async () => ({ name: 'My Team' })),
|
||||||
|
|
@ -299,6 +325,12 @@ describe('ipc teams handlers', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
handlers.clear();
|
handlers.clear();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
service.listTeams.mockReset();
|
||||||
|
service.getAllTasks.mockReset();
|
||||||
|
service.listTeams.mockResolvedValue([{ teamName: 'my-team', displayName: 'My Team' }]);
|
||||||
|
service.getAllTasks.mockResolvedValue([
|
||||||
|
{ id: 'task-1', teamName: 'my-team', subject: 'Task 1' },
|
||||||
|
]);
|
||||||
mockGetMembersMeta.mockReset();
|
mockGetMembersMeta.mockReset();
|
||||||
mockGetMembersMeta.mockResolvedValue([]);
|
mockGetMembersMeta.mockResolvedValue([]);
|
||||||
mockGetMembersMetaFile.mockReset();
|
mockGetMembersMetaFile.mockReset();
|
||||||
|
|
@ -315,6 +347,8 @@ describe('ipc teams handlers', () => {
|
||||||
mockTeamDataWorkerClient.getMemberActivityMeta.mockReset();
|
mockTeamDataWorkerClient.getMemberActivityMeta.mockReset();
|
||||||
mockTeamDataWorkerClient.findLogsForTask.mockReset();
|
mockTeamDataWorkerClient.findLogsForTask.mockReset();
|
||||||
mockTeamDataWorkerClient.invalidateTeamConfig.mockReset();
|
mockTeamDataWorkerClient.invalidateTeamConfig.mockReset();
|
||||||
|
mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset();
|
||||||
|
launchIoGovernor = new LaunchIoGovernor({ quietWindowMs: 100 });
|
||||||
initializeTeamHandlers(
|
initializeTeamHandlers(
|
||||||
service as never,
|
service as never,
|
||||||
provisioningService as never,
|
provisioningService as never,
|
||||||
|
|
@ -328,12 +362,14 @@ describe('ipc teams handlers', () => {
|
||||||
boardTaskActivityDetailService as never,
|
boardTaskActivityDetailService as never,
|
||||||
boardTaskLogStreamService as never,
|
boardTaskLogStreamService as never,
|
||||||
boardTaskExactLogsService as never,
|
boardTaskExactLogsService as never,
|
||||||
boardTaskExactLogDetailService as never
|
boardTaskExactLogDetailService as never,
|
||||||
|
launchIoGovernor
|
||||||
);
|
);
|
||||||
registerTeamHandlers(ipcMain as never);
|
registerTeamHandlers(ipcMain as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
launchIoGovernor.clearForTests();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
setClaudeBasePathOverride(null);
|
setClaudeBasePathOverride(null);
|
||||||
});
|
});
|
||||||
|
|
@ -1047,6 +1083,155 @@ describe('ipc teams handlers', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns cached TEAM_LIST data under active launch pressure without starting another scan', async () => {
|
||||||
|
const first = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
||||||
|
success: boolean;
|
||||||
|
data: { teamName: string }[];
|
||||||
|
};
|
||||||
|
expect(first.success).toBe(true);
|
||||||
|
expect(first.data).toEqual([{ teamName: 'my-team', displayName: 'My Team' }]);
|
||||||
|
|
||||||
|
service.listTeams.mockResolvedValueOnce([{ teamName: 'fresh-team', displayName: 'Fresh' }]);
|
||||||
|
launchIoGovernor.noteLaunchIntent('my-team', 'test');
|
||||||
|
|
||||||
|
const second = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
||||||
|
success: boolean;
|
||||||
|
data: { teamName: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(second.success).toBe(true);
|
||||||
|
expect(second.data).toEqual([{ teamName: 'my-team', displayName: 'My Team' }]);
|
||||||
|
expect(service.listTeams).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns cached TEAM_GET_ALL_TASKS data under active launch pressure without starting another scan', async () => {
|
||||||
|
const first = (await handlers.get(TEAM_GET_ALL_TASKS)!({} as never)) as {
|
||||||
|
success: boolean;
|
||||||
|
data: { id: string }[];
|
||||||
|
};
|
||||||
|
expect(first.success).toBe(true);
|
||||||
|
expect(first.data).toEqual([{ id: 'task-1', teamName: 'my-team', subject: 'Task 1' }]);
|
||||||
|
|
||||||
|
service.getAllTasks.mockResolvedValueOnce([
|
||||||
|
{ id: 'task-2', teamName: 'my-team', subject: 'Task 2' },
|
||||||
|
]);
|
||||||
|
launchIoGovernor.noteLaunchIntent('my-team', 'test');
|
||||||
|
|
||||||
|
const second = (await handlers.get(TEAM_GET_ALL_TASKS)!({} as never)) as {
|
||||||
|
success: boolean;
|
||||||
|
data: { id: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(second.success).toBe(true);
|
||||||
|
expect(second.data).toEqual([{ id: 'task-1', teamName: 'my-team', subject: 'Task 1' }]);
|
||||||
|
expect(service.getAllTasks).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps current fresh behavior for TEAM_LIST when launch pressure has no cached data', async () => {
|
||||||
|
launchIoGovernor.clearForTests();
|
||||||
|
launchIoGovernor.noteLaunchIntent('my-team', 'test');
|
||||||
|
|
||||||
|
const result = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
||||||
|
success: boolean;
|
||||||
|
data: { teamName: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual([{ teamName: 'my-team', displayName: 'My Team' }]);
|
||||||
|
expect(service.listTeams).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushes TEAM_LIST once after terminal provisioning progress quiet window', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const first = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
expect(first.success).toBe(true);
|
||||||
|
|
||||||
|
service.listTeams.mockResolvedValue([{ teamName: 'fresh-team', displayName: 'Fresh' }]);
|
||||||
|
launchIoGovernor.noteLaunchIntent('my-team', 'test');
|
||||||
|
await handlers.get(TEAM_LIST)!({} as never);
|
||||||
|
launchIoGovernor.noteProvisioningProgress({
|
||||||
|
runId: 'run-1',
|
||||||
|
teamName: 'my-team',
|
||||||
|
state: 'ready',
|
||||||
|
message: 'ready',
|
||||||
|
startedAt: '2026-05-02T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-05-02T00:00:00.000Z',
|
||||||
|
} as TeamProvisioningProgress);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(service.listTeams).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let provisioning status polling activate launch IO stale mode', async () => {
|
||||||
|
const first = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
||||||
|
success: boolean;
|
||||||
|
data: { teamName: string }[];
|
||||||
|
};
|
||||||
|
expect(first.success).toBe(true);
|
||||||
|
|
||||||
|
service.listTeams.mockResolvedValueOnce([{ teamName: 'fresh-team', displayName: 'Fresh' }]);
|
||||||
|
const status = (await handlers.get(TEAM_PROVISIONING_STATUS)!({} as never, 'run-1')) as {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
expect(status.success).toBe(true);
|
||||||
|
|
||||||
|
const second = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
||||||
|
success: boolean;
|
||||||
|
data: { teamName: string }[];
|
||||||
|
};
|
||||||
|
expect(second.success).toBe(true);
|
||||||
|
expect(second.data).toEqual([{ teamName: 'fresh-team', displayName: 'Fresh' }]);
|
||||||
|
expect(service.listTeams).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears launch IO pressure when create fails before first provisioning progress', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const first = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
expect(first.success).toBe(true);
|
||||||
|
provisioningService.createTeam.mockRejectedValueOnce(new Error('bootstrap failed early'));
|
||||||
|
service.listTeams
|
||||||
|
.mockResolvedValueOnce([{ teamName: 'background-fresh', displayName: 'Background Fresh' }])
|
||||||
|
.mockResolvedValueOnce([{ teamName: 'fresh-team', displayName: 'Fresh' }]);
|
||||||
|
|
||||||
|
const createResult = (await handlers.get(TEAM_CREATE)!(
|
||||||
|
{ sender: { send: vi.fn() } } as never,
|
||||||
|
{
|
||||||
|
teamName: 'my-team',
|
||||||
|
members: [{ name: 'alice' }],
|
||||||
|
cwd: os.tmpdir(),
|
||||||
|
}
|
||||||
|
)) as { success: boolean };
|
||||||
|
expect(createResult.success).toBe(false);
|
||||||
|
vi.mocked(console.error).mockClear();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
await flushMicrotasks();
|
||||||
|
const second = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
||||||
|
success: boolean;
|
||||||
|
data: { teamName: string }[];
|
||||||
|
};
|
||||||
|
expect(second.success).toBe(true);
|
||||||
|
expect(second.data).toEqual([{ teamName: 'fresh-team', displayName: 'Fresh' }]);
|
||||||
|
expect(service.listTeams).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not route TEAM_GET_MESSAGES_PAGE through the launch IO governor', async () => {
|
||||||
|
launchIoGovernor.noteLaunchIntent('my-team', 'test');
|
||||||
|
|
||||||
|
const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', {
|
||||||
|
limit: 50,
|
||||||
|
})) as { success: boolean; data?: { feedRevision: string } };
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.feedRevision).toBe('rev-1');
|
||||||
|
expect(service.getMessagesPage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps TEAM_GET_DATA structural and does not expose message transport', async () => {
|
it('keeps TEAM_GET_DATA structural and does not expose message transport', async () => {
|
||||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||||
{
|
{
|
||||||
|
|
@ -1089,6 +1274,137 @@ describe('ipc teams handlers', () => {
|
||||||
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('classifies draft teams before asking the team-data worker for a full snapshot', async () => {
|
||||||
|
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-get-data-'));
|
||||||
|
setClaudeBasePathOverride(claudeRoot);
|
||||||
|
const teamDir = path.join(claudeRoot, 'teams', 'draft-team');
|
||||||
|
await fs.promises.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.promises.writeFile(
|
||||||
|
path.join(teamDir, 'team.meta.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
cwd: '/tmp/draft-team',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handler = handlers.get(TEAM_GET_DATA)!;
|
||||||
|
const result = (await handler({} as never, 'draft-team')) as {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: false, error: 'TEAM_DRAFT' });
|
||||||
|
expect(mockTeamDataWorkerClient.getTeamData).not.toHaveBeenCalled();
|
||||||
|
expect(service.getTeamData).not.toHaveBeenCalledWith('draft-team');
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(claudeRoot, { recursive: true, force: true });
|
||||||
|
setClaudeBasePathOverride(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies draft teams before falling back to main-thread getTeamData', async () => {
|
||||||
|
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-main-get-data-'));
|
||||||
|
setClaudeBasePathOverride(claudeRoot);
|
||||||
|
const teamDir = path.join(claudeRoot, 'teams', 'draft-team');
|
||||||
|
await fs.promises.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.promises.writeFile(
|
||||||
|
path.join(teamDir, 'team.meta.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
cwd: '/tmp/draft-team',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handler = handlers.get(TEAM_GET_DATA)!;
|
||||||
|
const result = (await handler({} as never, 'draft-team')) as {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: false, error: 'TEAM_DRAFT' });
|
||||||
|
expect(mockTeamDataWorkerClient.getTeamData).not.toHaveBeenCalled();
|
||||||
|
expect(service.getTeamData).not.toHaveBeenCalledWith('draft-team');
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(claudeRoot, { recursive: true, force: true });
|
||||||
|
setClaudeBasePathOverride(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let slow draft metadata classification block normal getData fallback', async () => {
|
||||||
|
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-slow-meta-'));
|
||||||
|
setClaudeBasePathOverride(claudeRoot);
|
||||||
|
const teamDir = path.join(claudeRoot, 'teams', 'slow-meta-team');
|
||||||
|
await fs.promises.mkdir(teamDir, { recursive: true });
|
||||||
|
const { TeamMetaStore } = await import('../../../src/main/services/team/TeamMetaStore');
|
||||||
|
const metaSpy = vi
|
||||||
|
.spyOn(TeamMetaStore.prototype, 'getMeta')
|
||||||
|
.mockImplementation(async () => new Promise(() => undefined));
|
||||||
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
||||||
|
mockTeamDataWorkerClient.getTeamData.mockResolvedValueOnce({
|
||||||
|
teamName: 'slow-meta-team',
|
||||||
|
config: { name: 'Slow Meta Team' },
|
||||||
|
tasks: [],
|
||||||
|
members: [],
|
||||||
|
kanbanState: { teamName: 'slow-meta-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const handler = handlers.get(TEAM_GET_DATA)!;
|
||||||
|
const result = (await handler({} as never, 'slow-meta-team')) as {
|
||||||
|
success: boolean;
|
||||||
|
data?: { teamName: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(Date.now() - startedAt).toBeLessThan(1500);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.teamName).toBe('slow-meta-team');
|
||||||
|
expect(mockTeamDataWorkerClient.getTeamData).toHaveBeenCalledWith('slow-meta-team');
|
||||||
|
} finally {
|
||||||
|
metaSpy.mockRestore();
|
||||||
|
await fs.promises.rm(claudeRoot, { recursive: true, force: true });
|
||||||
|
setClaudeBasePathOverride(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let slow draft metadata classification block Team not found fallback', async () => {
|
||||||
|
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-slow-missing-meta-'));
|
||||||
|
setClaudeBasePathOverride(claudeRoot);
|
||||||
|
const teamDir = path.join(claudeRoot, 'teams', 'slow-missing-team');
|
||||||
|
await fs.promises.mkdir(teamDir, { recursive: true });
|
||||||
|
const { TeamMetaStore } = await import('../../../src/main/services/team/TeamMetaStore');
|
||||||
|
const metaSpy = vi
|
||||||
|
.spyOn(TeamMetaStore.prototype, 'getMeta')
|
||||||
|
.mockImplementation(async () => new Promise(() => undefined));
|
||||||
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||||
|
service.getTeamData.mockRejectedValueOnce(new Error('Team not found: slow-missing-team'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const handler = handlers.get(TEAM_GET_DATA)!;
|
||||||
|
const result = (await handler({} as never, 'slow-missing-team')) as {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(Date.now() - startedAt).toBeLessThan(1500);
|
||||||
|
expect(result).toEqual({ success: false, error: 'Team not found: slow-missing-team' });
|
||||||
|
expect(service.getTeamData).toHaveBeenCalledWith('slow-missing-team');
|
||||||
|
vi.mocked(console.error).mockClear();
|
||||||
|
} finally {
|
||||||
|
metaSpy.mockRestore();
|
||||||
|
await fs.promises.rm(claudeRoot, { recursive: true, force: true });
|
||||||
|
setClaudeBasePathOverride(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('does not let a live duplicate of the same session rate-limit reply delay auto-resume', async () => {
|
it('does not let a live duplicate of the same session rate-limit reply delay auto-resume', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date('2026-04-17T12:00:30.000Z'));
|
vi.setSystemTime(new Date('2026-04-17T12:00:30.000Z'));
|
||||||
|
|
@ -1221,6 +1537,7 @@ describe('ipc teams handlers', () => {
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.data.feedRevision).toBe('rev-worker');
|
expect(result.data.feedRevision).toBe('rev-worker');
|
||||||
|
await flushMicrotasks();
|
||||||
expect(mockAddTeamNotification).toHaveBeenCalledWith(
|
expect(mockAddTeamNotification).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
teamEventType: 'rate_limit',
|
teamEventType: 'rate_limit',
|
||||||
|
|
@ -1233,6 +1550,47 @@ describe('ipc teams handlers', () => {
|
||||||
expect(service.getMessageFeed).not.toHaveBeenCalled();
|
expect(service.getMessageFeed).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not block TEAM_GET_MESSAGES_PAGE on notification context reads', async () => {
|
||||||
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
||||||
|
mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
from: 'team-lead',
|
||||||
|
text: "You've hit your limit. Please wait a bit before retrying.",
|
||||||
|
timestamp: '2026-02-23T10:00:01.000Z',
|
||||||
|
read: true,
|
||||||
|
source: 'lead_session' as const,
|
||||||
|
messageId: 'msg-rate-limit-nonblocking',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
feedRevision: 'rev-worker',
|
||||||
|
});
|
||||||
|
const context = createDeferred<{ displayName: string; projectPath: string }>();
|
||||||
|
service.getTeamNotificationContext.mockReturnValueOnce(context.promise);
|
||||||
|
|
||||||
|
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
|
||||||
|
const result = (await handler({} as never, 'my-team', {
|
||||||
|
limit: 50,
|
||||||
|
})) as { success: boolean; data: { feedRevision: string } };
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data.feedRevision).toBe('rev-worker');
|
||||||
|
expect(mockAddTeamNotification).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
context.resolve({ displayName: 'My Team', projectPath: '/tmp/project' });
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(mockAddTeamNotification).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
teamEventType: 'rate_limit',
|
||||||
|
teamName: 'my-team',
|
||||||
|
teamDisplayName: 'My Team',
|
||||||
|
dedupeKey: 'rate-limit:my-team:msg-rate-limit-nonblocking',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back TEAM_GET_MESSAGES_PAGE to the main thread in packaged runtime when worker is unavailable', async () => {
|
it('falls back TEAM_GET_MESSAGES_PAGE to the main thread in packaged runtime when worker is unavailable', async () => {
|
||||||
const electron = await import('electron');
|
const electron = await import('electron');
|
||||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||||
|
|
@ -2130,6 +2488,7 @@ describe('ipc teams handlers', () => {
|
||||||
description: undefined,
|
description: undefined,
|
||||||
color: undefined,
|
color: undefined,
|
||||||
});
|
});
|
||||||
|
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
|
||||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
||||||
'my-team',
|
'my-team',
|
||||||
'The team has been renamed to "Renamed Team". Please use this name when referring to the team going forward.'
|
'The team has been renamed to "Renamed Team". Please use this name when referring to the team going forward.'
|
||||||
|
|
@ -2155,6 +2514,33 @@ describe('ipc teams handlers', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('team mutation cache invalidation', () => {
|
||||||
|
it('invalidates worker config cache after delete, restore, and permanent delete', async () => {
|
||||||
|
const deleteHandler = handlers.get(TEAM_DELETE_TEAM)!;
|
||||||
|
const restoreHandler = handlers.get(TEAM_RESTORE)!;
|
||||||
|
const permanentlyDeleteHandler = handlers.get(TEAM_PERMANENTLY_DELETE)!;
|
||||||
|
|
||||||
|
let result = (await deleteHandler({} as never, 'my-team')) as { success: boolean };
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(service.deleteTeam).toHaveBeenCalledWith('my-team');
|
||||||
|
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
|
||||||
|
|
||||||
|
mockTeamDataWorkerClient.invalidateTeamConfig.mockClear();
|
||||||
|
|
||||||
|
result = (await restoreHandler({} as never, 'my-team')) as { success: boolean };
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(service.restoreTeam).toHaveBeenCalledWith('my-team');
|
||||||
|
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
|
||||||
|
|
||||||
|
mockTeamDataWorkerClient.invalidateTeamConfig.mockClear();
|
||||||
|
|
||||||
|
result = (await permanentlyDeleteHandler({} as never, 'my-team')) as { success: boolean };
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(service.permanentlyDeleteTeam).toHaveBeenCalledWith('my-team');
|
||||||
|
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('removeMember', () => {
|
describe('removeMember', () => {
|
||||||
it('calls service on valid input', async () => {
|
it('calls service on valid input', async () => {
|
||||||
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
||||||
|
|
@ -2908,6 +3294,7 @@ describe('ipc teams handlers', () => {
|
||||||
cwd: os.tmpdir(),
|
cwd: os.tmpdir(),
|
||||||
})) as { success: boolean };
|
})) as { success: boolean };
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('solo-team');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handleCreateConfig preserves draft launch metadata', async () => {
|
it('handleCreateConfig preserves draft launch metadata', async () => {
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,24 @@ describe('FileWatcher', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits config team-change events for team and members metadata changes', () => {
|
||||||
|
const dataCache = new DataCache(50, 10, false);
|
||||||
|
const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos');
|
||||||
|
const events: unknown[] = [];
|
||||||
|
watcher.on('team-change', (event) => events.push(event));
|
||||||
|
|
||||||
|
const testWatcher = watcher as unknown as {
|
||||||
|
processTeamsChange: (eventType: string, filename: string) => void;
|
||||||
|
};
|
||||||
|
testWatcher.processTeamsChange('change', 'team-a/team.meta.json');
|
||||||
|
testWatcher.processTeamsChange('change', 'team-a/members.meta.json');
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ type: 'config', teamName: 'team-a', detail: 'team.meta.json' },
|
||||||
|
{ type: 'config', teamName: 'team-a', detail: 'members.meta.json' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps append offset pinned for partial trailing lines until completed', async () => {
|
it('keeps append offset pinned for partial trailing lines until completed', async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-'));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-'));
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,10 @@ function makeConfig(overrides: Partial<TeamConfig> = {}): TeamConfig {
|
||||||
describe('CrossTeamService', () => {
|
describe('CrossTeamService', () => {
|
||||||
let service: CrossTeamService;
|
let service: CrossTeamService;
|
||||||
let configReader: { getConfig: ReturnType<typeof vi.fn> };
|
let configReader: { getConfig: ReturnType<typeof vi.fn> };
|
||||||
let dataService: { getLeadMemberName: ReturnType<typeof vi.fn> };
|
let dataService: {
|
||||||
|
getLeadMemberName: ReturnType<typeof vi.fn>;
|
||||||
|
listTeams: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
let inboxWriter: { sendMessage: ReturnType<typeof vi.fn> };
|
let inboxWriter: { sendMessage: ReturnType<typeof vi.fn> };
|
||||||
let provisioning: {
|
let provisioning: {
|
||||||
isTeamAlive: ReturnType<typeof vi.fn>;
|
isTeamAlive: ReturnType<typeof vi.fn>;
|
||||||
|
|
@ -68,6 +71,7 @@ describe('CrossTeamService', () => {
|
||||||
};
|
};
|
||||||
dataService = {
|
dataService = {
|
||||||
getLeadMemberName: vi.fn().mockResolvedValue('team-lead'),
|
getLeadMemberName: vi.fn().mockResolvedValue('team-lead'),
|
||||||
|
listTeams: vi.fn().mockResolvedValue([]),
|
||||||
};
|
};
|
||||||
inboxWriter = {
|
inboxWriter = {
|
||||||
sendMessage: vi.fn().mockResolvedValue({ deliveredToInbox: true, messageId: 'mock-id' }),
|
sendMessage: vi.fn().mockResolvedValue({ deliveredToInbox: true, messageId: 'mock-id' }),
|
||||||
|
|
@ -353,11 +357,65 @@ describe('CrossTeamService', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('listAvailableTargets', () => {
|
describe('listAvailableTargets', () => {
|
||||||
it('returns empty when teams dir read fails', async () => {
|
it('returns empty when team summary listing fails', async () => {
|
||||||
configReader.getConfig.mockRejectedValue(new Error('ENOENT'));
|
dataService.listTeams.mockRejectedValue(new Error('ENOENT'));
|
||||||
const result = await service.listAvailableTargets();
|
const result = await service.listAvailableTargets();
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses team summaries instead of verified config reads for target discovery', async () => {
|
||||||
|
dataService.listTeams.mockResolvedValue([
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
displayName: 'Team A',
|
||||||
|
description: '',
|
||||||
|
memberCount: 1,
|
||||||
|
members: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teamName: 'team-b',
|
||||||
|
displayName: 'Team B',
|
||||||
|
description: 'Target team',
|
||||||
|
color: 'blue',
|
||||||
|
memberCount: 1,
|
||||||
|
members: [{ name: 'alice', color: '#abcdef' }],
|
||||||
|
leadName: 'captain',
|
||||||
|
leadColor: '#123456',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teamName: 'deleted-team',
|
||||||
|
displayName: 'Deleted',
|
||||||
|
description: '',
|
||||||
|
memberCount: 0,
|
||||||
|
members: [],
|
||||||
|
deletedAt: '2026-05-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teamName: 'draft-team',
|
||||||
|
displayName: 'Draft',
|
||||||
|
description: '',
|
||||||
|
memberCount: 0,
|
||||||
|
members: [],
|
||||||
|
pendingCreate: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
provisioning.isTeamAlive.mockImplementation((teamName: string) => teamName === 'team-b');
|
||||||
|
|
||||||
|
const result = await service.listAvailableTargets('team-a');
|
||||||
|
|
||||||
|
expect(configReader.getConfig).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
teamName: 'team-b',
|
||||||
|
displayName: 'Team B',
|
||||||
|
description: 'Target team',
|
||||||
|
color: 'blue',
|
||||||
|
leadName: 'captain',
|
||||||
|
leadColor: '#123456',
|
||||||
|
isOnline: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getOutbox', () => {
|
describe('getOutbox', () => {
|
||||||
|
|
|
||||||
338
test/main/services/team/LaunchIoGovernor.test.ts
Normal file
338
test/main/services/team/LaunchIoGovernor.test.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
cloneLaunchIoGovernorPayload,
|
||||||
|
LaunchIoGovernor,
|
||||||
|
} from '../../../../src/main/services/team/LaunchIoGovernor';
|
||||||
|
import type { GlobalTask, TeamProvisioningProgress, TeamSummary } from '../../../../src/shared/types';
|
||||||
|
|
||||||
|
function team(teamName: string): TeamSummary {
|
||||||
|
return { teamName, displayName: teamName } as TeamSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function task(id: string): GlobalTask {
|
||||||
|
return { id, teamName: 'team-a', subject: id } as GlobalTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
function progress(teamName: string, state: string): TeamProvisioningProgress {
|
||||||
|
return {
|
||||||
|
runId: `run-${teamName}`,
|
||||||
|
teamName,
|
||||||
|
state,
|
||||||
|
message: state,
|
||||||
|
startedAt: '2026-05-02T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-05-02T00:00:00.000Z',
|
||||||
|
} as TeamProvisioningProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LaunchIoGovernor', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs fresh and caches success when there is no launch pressure', async () => {
|
||||||
|
const governor = new LaunchIoGovernor();
|
||||||
|
const loadFresh = vi.fn(async () => [team('fresh')]);
|
||||||
|
|
||||||
|
const result = await governor.runSummaryOperation('teams:list', loadFresh, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([team('fresh')]);
|
||||||
|
expect(loadFresh).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns bounded stale cache under active launch pressure and schedules no duplicate fresh read', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let now = 0;
|
||||||
|
const governor = new LaunchIoGovernor({ now: () => now, quietWindowMs: 100 });
|
||||||
|
const loadFresh = vi.fn(async () => [team('old')]);
|
||||||
|
|
||||||
|
await governor.runSummaryOperation('teams:list', loadFresh, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
loadFresh.mockResolvedValue([team('new')]);
|
||||||
|
|
||||||
|
governor.noteLaunchIntent('team-a', 'launch');
|
||||||
|
const result = await governor.runSummaryOperation('teams:list', loadFresh, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([team('old')]);
|
||||||
|
expect(loadFresh).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
now += 99;
|
||||||
|
await vi.advanceTimersByTimeAsync(99);
|
||||||
|
expect(loadFresh).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isolates cached payload from caller-side mutations', async () => {
|
||||||
|
const governor = new LaunchIoGovernor();
|
||||||
|
const loadFresh = vi.fn(async () => [team('old')]);
|
||||||
|
|
||||||
|
const first = await governor.runSummaryOperation('teams:list', loadFresh, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
first[0]!.displayName = 'mutated';
|
||||||
|
|
||||||
|
governor.noteLaunchIntent('team-a', 'launch');
|
||||||
|
const second = await governor.runSummaryOperation('teams:list', loadFresh, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second).toEqual([team('old')]);
|
||||||
|
expect(loadFresh).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs one fresh read and coalesces callers when pressure has no cache', async () => {
|
||||||
|
const governor = new LaunchIoGovernor();
|
||||||
|
const deferred = createDeferred<TeamSummary[]>();
|
||||||
|
const loadFresh = vi.fn(() => deferred.promise);
|
||||||
|
|
||||||
|
governor.noteLaunchIntent('team-a', 'launch');
|
||||||
|
const first = governor.runSummaryOperation('teams:list', loadFresh, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
const second = governor.runSummaryOperation('teams:list', loadFresh, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(loadFresh).toHaveBeenCalledTimes(1);
|
||||||
|
deferred.resolve([team('fresh')]);
|
||||||
|
await expect(Promise.all([first, second])).resolves.toEqual([[team('fresh')], [team('fresh')]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not serve cache beyond max stale age during launch pressure', async () => {
|
||||||
|
let now = 0;
|
||||||
|
const governor = new LaunchIoGovernor({ now: () => now, maxStaleAgeMs: 100 });
|
||||||
|
const loadFresh = vi.fn(async () => [team('old')]);
|
||||||
|
|
||||||
|
await governor.runSummaryOperation('teams:list', loadFresh, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
now = 101;
|
||||||
|
loadFresh.mockResolvedValue([team('new')]);
|
||||||
|
governor.noteLaunchIntent('team-a', 'launch');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
governor.runSummaryOperation('teams:list', loadFresh, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
})
|
||||||
|
).resolves.toEqual([team('new')]);
|
||||||
|
expect(loadFresh).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cache an in-flight result when a dirty generation arrives before it resolves', async () => {
|
||||||
|
const governor = new LaunchIoGovernor();
|
||||||
|
const deferred = createDeferred<TeamSummary[]>();
|
||||||
|
const loadFresh = vi.fn(() => deferred.promise);
|
||||||
|
|
||||||
|
const first = governor.runSummaryOperation('teams:list', loadFresh, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
governor.noteLaunchIntent('team-a', 'launch');
|
||||||
|
deferred.resolve([team('stale-inflight')]);
|
||||||
|
await expect(first).resolves.toEqual([team('stale-inflight')]);
|
||||||
|
|
||||||
|
loadFresh.mockResolvedValue([team('fresh-after-dirty')]);
|
||||||
|
await expect(
|
||||||
|
governor.runSummaryOperation('teams:list', loadFresh, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
})
|
||||||
|
).resolves.toEqual([team('fresh-after-dirty')]);
|
||||||
|
expect(loadFresh).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks config and task changes dirty for the correct summary operations', async () => {
|
||||||
|
const governor = new LaunchIoGovernor();
|
||||||
|
const loadTeams = vi.fn(async () => [team('team-old')]);
|
||||||
|
const loadTasks = vi.fn(async () => [task('task-old')]);
|
||||||
|
|
||||||
|
await governor.runSummaryOperation('teams:list', loadTeams, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
await governor.runSummaryOperation('teams:getAllTasks', loadTasks, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
loadTeams.mockResolvedValue([team('team-new')]);
|
||||||
|
loadTasks.mockResolvedValue([task('task-new')]);
|
||||||
|
governor.noteLaunchIntent('team-a', 'launch');
|
||||||
|
governor.noteTeamChange({ type: 'task', teamName: 'team-a', detail: 'task.json' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
governor.runSummaryOperation('teams:list', loadTeams, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
})
|
||||||
|
).resolves.toEqual([team('team-old')]);
|
||||||
|
await expect(
|
||||||
|
governor.runSummaryOperation('teams:getAllTasks', loadTasks, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
})
|
||||||
|
).resolves.toEqual([task('task-old')]);
|
||||||
|
expect(loadTeams).toHaveBeenCalledTimes(1);
|
||||||
|
expect(loadTasks).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not start background refresh for dirty events outside launch pressure', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const governor = new LaunchIoGovernor({ quietWindowMs: 100 });
|
||||||
|
const loadTeams = vi.fn(async () => [team('old')]);
|
||||||
|
|
||||||
|
await governor.runSummaryOperation('teams:list', loadTeams, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
loadTeams.mockResolvedValue([team('new')]);
|
||||||
|
|
||||||
|
governor.noteTeamChange({ type: 'config', teamName: 'team-a', detail: 'config.json' });
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(loadTeams).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
governor.runSummaryOperation('teams:list', loadTeams, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
})
|
||||||
|
).resolves.toEqual([team('new')]);
|
||||||
|
expect(loadTeams).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mark global tasks dirty from launch intent alone', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let now = 0;
|
||||||
|
const governor = new LaunchIoGovernor({ now: () => now, quietWindowMs: 100 });
|
||||||
|
const loadTeams = vi.fn(async () => [team('old-team')]);
|
||||||
|
const loadTasks = vi.fn(async () => [task('old-task')]);
|
||||||
|
|
||||||
|
await governor.runSummaryOperation('teams:list', loadTeams, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
await governor.runSummaryOperation('teams:getAllTasks', loadTasks, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
loadTeams.mockResolvedValue([team('new-team')]);
|
||||||
|
loadTasks.mockResolvedValue([task('new-task')]);
|
||||||
|
|
||||||
|
governor.noteLaunchIntent('team-a', 'launch');
|
||||||
|
await governor.runSummaryOperation('teams:list', loadTeams, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
await governor.runSummaryOperation('teams:getAllTasks', loadTasks, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
governor.noteProvisioningProgress(progress('team-a', 'ready'));
|
||||||
|
|
||||||
|
now += 100;
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(loadTeams).toHaveBeenCalledTimes(2);
|
||||||
|
expect(loadTasks).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps quiet window after terminal progress and flushes dirty cache once timer expires', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let now = 0;
|
||||||
|
const governor = new LaunchIoGovernor({ now: () => now, quietWindowMs: 100 });
|
||||||
|
const loadFresh = vi.fn(async () => [team('old')]);
|
||||||
|
const loadTasks = vi.fn(async () => [task('old')]);
|
||||||
|
|
||||||
|
await governor.runSummaryOperation('teams:list', loadFresh, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
await governor.runSummaryOperation('teams:getAllTasks', loadTasks, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
loadFresh.mockResolvedValue([team('new')]);
|
||||||
|
loadTasks.mockResolvedValue([task('new')]);
|
||||||
|
|
||||||
|
governor.noteLaunchIntent('team-a', 'launch');
|
||||||
|
await governor.runSummaryOperation('teams:list', loadFresh, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
await governor.runSummaryOperation('teams:getAllTasks', loadTasks, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
governor.noteTeamChange({ type: 'config', teamName: 'team-a', detail: 'config.json' });
|
||||||
|
governor.noteProvisioningProgress(progress('team-a', 'ready'));
|
||||||
|
|
||||||
|
now += 99;
|
||||||
|
await vi.advanceTimersByTimeAsync(99);
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(loadFresh).toHaveBeenCalledTimes(1);
|
||||||
|
expect(loadTasks).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
now += 1;
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(loadFresh).toHaveBeenCalledTimes(2);
|
||||||
|
expect(loadTasks).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps launch pressure until all concurrent launches reach terminal states', () => {
|
||||||
|
let now = 0;
|
||||||
|
const governor = new LaunchIoGovernor({ now: () => now, quietWindowMs: 100 });
|
||||||
|
|
||||||
|
governor.noteLaunchIntent('team-a', 'launch');
|
||||||
|
governor.noteLaunchIntent('team-b', 'launch');
|
||||||
|
governor.noteProvisioningProgress(progress('team-a', 'failed'));
|
||||||
|
expect(governor.hasLaunchPressureForTests()).toBe(true);
|
||||||
|
|
||||||
|
governor.noteProvisioningProgress(progress('team-b', 'ready'));
|
||||||
|
expect(governor.hasLaunchPressureForTests()).toBe(true);
|
||||||
|
|
||||||
|
now += 100;
|
||||||
|
expect(governor.hasLaunchPressureForTests()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves old cache and dirty state when a deferred refresh fails', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let now = 0;
|
||||||
|
const logger = { warn: vi.fn() };
|
||||||
|
const governor = new LaunchIoGovernor({ now: () => now, quietWindowMs: 100, logger });
|
||||||
|
const loadFresh = vi.fn(async () => [team('old')]);
|
||||||
|
|
||||||
|
await governor.runSummaryOperation('teams:list', loadFresh, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
loadFresh.mockRejectedValueOnce(new Error('worker timeout'));
|
||||||
|
|
||||||
|
governor.noteLaunchIntent('team-a', 'launch');
|
||||||
|
governor.noteTeamChange({ type: 'config', teamName: 'team-a', detail: 'config.json' });
|
||||||
|
await governor.runSummaryOperation('teams:list', loadFresh, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
});
|
||||||
|
governor.noteProvisioningProgress(progress('team-a', 'ready'));
|
||||||
|
|
||||||
|
now += 100;
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('deferred refresh failed'));
|
||||||
|
governor.noteLaunchIntent('team-b', 'launch');
|
||||||
|
await expect(
|
||||||
|
governor.runSummaryOperation('teams:list', loadFresh, {
|
||||||
|
clone: cloneLaunchIoGovernorPayload,
|
||||||
|
})
|
||||||
|
).resolves.toEqual([team('old')]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -200,6 +200,118 @@ describe('TeamConfigReader', () => {
|
||||||
expect(teams[0]?.missingMembers).toBeUndefined();
|
expect(teams[0]?.missingMembers).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exposes lead summary fields without adding lead to teammate member chips', async () => {
|
||||||
|
const teamName = 'lead-summary-team';
|
||||||
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(teamDir, 'config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Lead Summary Team',
|
||||||
|
members: [
|
||||||
|
{ name: 'captain', agentType: 'team-lead', color: '#123456' },
|
||||||
|
{ name: 'alice', role: 'reviewer', color: '#abcdef' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const reader = new TeamConfigReader();
|
||||||
|
const teams = await reader.listTeams();
|
||||||
|
|
||||||
|
expect(teams).toHaveLength(1);
|
||||||
|
expect(teams[0]).toMatchObject({
|
||||||
|
teamName,
|
||||||
|
displayName: 'Lead Summary Team',
|
||||||
|
memberCount: 1,
|
||||||
|
members: [{ name: 'alice', role: 'reviewer', color: '#abcdef' }],
|
||||||
|
leadName: 'captain',
|
||||||
|
leadColor: '#123456',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupes and briefly caches listTeams scans until invalidated', async () => {
|
||||||
|
const teamName = 'cached-list-team';
|
||||||
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(teamDir, 'config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Cached List Team',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const readdirSpy = vi.spyOn(nodeFs.promises, 'readdir');
|
||||||
|
|
||||||
|
const reader = new TeamConfigReader();
|
||||||
|
const [first, second] = await Promise.all([reader.listTeams(), reader.listTeams()]);
|
||||||
|
const readdirAfterFirstBatch = readdirSpy.mock.calls.length;
|
||||||
|
|
||||||
|
expect(first).toHaveLength(1);
|
||||||
|
expect(second).toHaveLength(1);
|
||||||
|
|
||||||
|
await reader.listTeams();
|
||||||
|
expect(readdirSpy).toHaveBeenCalledTimes(readdirAfterFirstBatch);
|
||||||
|
|
||||||
|
TeamConfigReader.invalidateTeam(teamName);
|
||||||
|
await reader.listTeams();
|
||||||
|
expect(readdirSpy.mock.calls.length).toBeGreaterThan(readdirAfterFirstBatch);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reuse a stale in-flight listTeams scan after invalidation', async () => {
|
||||||
|
const teamName = 'inflight-invalidated-list-team';
|
||||||
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(teamDir, 'config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Before Invalidation',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstReadStarted = createDeferred<void>();
|
||||||
|
const releaseFirstRead = createDeferred<void>();
|
||||||
|
const originalReaddir = nodeFs.promises.readdir.bind(nodeFs.promises);
|
||||||
|
let blockedFirstTeamScan = false;
|
||||||
|
const readdirSpy = vi
|
||||||
|
.spyOn(nodeFs.promises, 'readdir')
|
||||||
|
.mockImplementation(async (...args: unknown[]) => {
|
||||||
|
if (!blockedFirstTeamScan && args[0] === tempDir) {
|
||||||
|
blockedFirstTeamScan = true;
|
||||||
|
firstReadStarted.resolve();
|
||||||
|
await releaseFirstRead.promise;
|
||||||
|
}
|
||||||
|
return originalReaddir(...(args as Parameters<typeof nodeFs.promises.readdir>));
|
||||||
|
});
|
||||||
|
|
||||||
|
const reader = new TeamConfigReader();
|
||||||
|
const first = reader.listTeams();
|
||||||
|
await firstReadStarted.promise;
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(teamDir, 'config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'After Invalidation',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
TeamConfigReader.invalidateTeam(teamName);
|
||||||
|
|
||||||
|
const second = reader.listTeams();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const teamDirReads = readdirSpy.mock.calls.filter((call) => call[0] === tempDir);
|
||||||
|
expect(teamDirReads.length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
releaseFirstRead.resolve();
|
||||||
|
const [, secondTeams] = await Promise.all([first, second]);
|
||||||
|
expect(secondTeams[0]?.displayName).toBe('After Invalidation');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not let a removed base member hide an active auto-suffixed teammate in team summaries', async () => {
|
it('does not let a removed base member hide an active auto-suffixed teammate in team summaries', async () => {
|
||||||
const teamName = 'suffix-team';
|
const teamName = 'suffix-team';
|
||||||
const teamDir = path.join(tempDir, teamName);
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
|
@ -254,7 +366,7 @@ describe('TeamConfigReader', () => {
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 1,
|
version: 1,
|
||||||
members: [
|
members: [
|
||||||
{ name: 'team-lead', agentType: 'team-lead' },
|
{ name: 'team-lead', agentType: 'team-lead', color: '#123456' },
|
||||||
{ name: 'alice', removedAt: Date.now() - 60_000 },
|
{ name: 'alice', removedAt: Date.now() - 60_000 },
|
||||||
{ name: 'bob', role: 'developer' },
|
{ name: 'bob', role: 'developer' },
|
||||||
],
|
],
|
||||||
|
|
@ -269,6 +381,46 @@ describe('TeamConfigReader', () => {
|
||||||
teamName,
|
teamName,
|
||||||
displayName: 'Draft Summary Team',
|
displayName: 'Draft Summary Team',
|
||||||
memberCount: 1,
|
memberCount: 1,
|
||||||
|
leadName: 'team-lead',
|
||||||
|
leadColor: '#123456',
|
||||||
|
pendingCreate: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses injected members meta store for draft team summaries', async () => {
|
||||||
|
const teamName = 'draft-summary-injected-store-team';
|
||||||
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(teamDir, 'team.meta.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
cwd: tempDir,
|
||||||
|
displayName: 'Injected Draft Team',
|
||||||
|
createdAt: Date.parse('2026-04-22T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(teamDir, 'members.meta.json'),
|
||||||
|
JSON.stringify({ version: 1, members: [] }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const getMembers = vi.fn(async () => [
|
||||||
|
{ name: 'captain', agentType: 'team-lead', color: '#123456' },
|
||||||
|
{ name: 'alice', role: 'developer' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const reader = new TeamConfigReader({ getMembers } as never);
|
||||||
|
const teams = await reader.listTeams();
|
||||||
|
|
||||||
|
expect(getMembers).toHaveBeenCalledWith(teamName);
|
||||||
|
expect(teams[0]).toMatchObject({
|
||||||
|
teamName,
|
||||||
|
displayName: 'Injected Draft Team',
|
||||||
|
memberCount: 1,
|
||||||
|
leadName: 'captain',
|
||||||
|
leadColor: '#123456',
|
||||||
pendingCreate: true,
|
pendingCreate: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -332,6 +484,59 @@ describe('TeamConfigReader', () => {
|
||||||
expect(readFileSpy).toHaveBeenCalledTimes(1);
|
expect(readFileSpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('logs slow config reads with mode, likely cause, generation, and caller diagnostics', async () => {
|
||||||
|
const teamName = 'slow-read-diagnostics-team';
|
||||||
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
const configPath = path.join(teamDir, 'config.json');
|
||||||
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Slow Diagnostics Team',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(performance, 'now')
|
||||||
|
.mockReturnValueOnce(0)
|
||||||
|
.mockReturnValueOnce(0)
|
||||||
|
.mockReturnValueOnce(1)
|
||||||
|
.mockReturnValueOnce(1)
|
||||||
|
.mockReturnValueOnce(2_001)
|
||||||
|
.mockReturnValueOnce(2_001)
|
||||||
|
.mockReturnValueOnce(2_001)
|
||||||
|
.mockReturnValueOnce(2_001)
|
||||||
|
.mockReturnValueOnce(2_001);
|
||||||
|
|
||||||
|
const reader = new TeamConfigReader();
|
||||||
|
expect((await reader.getConfigVerified(teamName))?.name).toBe('Slow Diagnostics Team');
|
||||||
|
|
||||||
|
const slowLog = warnSpy.mock.calls.find((call) =>
|
||||||
|
String(call[1] ?? '').includes('[getConfig] slow read diag=')
|
||||||
|
);
|
||||||
|
expect(slowLog).toBeTruthy();
|
||||||
|
const rawMessage = String(slowLog?.[1] ?? '');
|
||||||
|
const diag = JSON.parse(rawMessage.slice(rawMessage.indexOf('diag=') + 'diag='.length)) as {
|
||||||
|
mode: string;
|
||||||
|
configPath: string;
|
||||||
|
likelyCause: string;
|
||||||
|
readMs: number;
|
||||||
|
cacheGeneration: number;
|
||||||
|
currentGeneration: number;
|
||||||
|
caller: string | null;
|
||||||
|
};
|
||||||
|
expect(diag).toMatchObject({
|
||||||
|
mode: 'verified',
|
||||||
|
configPath,
|
||||||
|
likelyCause: 'io_read_slow',
|
||||||
|
readMs: 2000,
|
||||||
|
cacheGeneration: 0,
|
||||||
|
currentGeneration: 0,
|
||||||
|
});
|
||||||
|
expect(diag.caller).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('shares in-flight snapshot stat and read work for concurrent calls', async () => {
|
it('shares in-flight snapshot stat and read work for concurrent calls', async () => {
|
||||||
const teamName = 'snapshot-inflight-team';
|
const teamName = 'snapshot-inflight-team';
|
||||||
const teamDir = path.join(tempDir, teamName);
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
|
@ -511,6 +716,54 @@ describe('TeamConfigReader', () => {
|
||||||
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Fresh Prime');
|
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Fresh Prime');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not reuse stale in-flight verified reads after app-owned primeConfig', async () => {
|
||||||
|
const teamName = 'verified-stale-read-prime-team';
|
||||||
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
const configPath = path.join(teamDir, 'config.json');
|
||||||
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
const staleRaw = JSON.stringify({
|
||||||
|
name: 'Stale Verified Read',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
});
|
||||||
|
await fs.writeFile(configPath, staleRaw, 'utf8');
|
||||||
|
|
||||||
|
const readDeferred = createDeferred<string>();
|
||||||
|
const realReadFile = nodeFs.promises.readFile.bind(nodeFs.promises);
|
||||||
|
let intercepted = false;
|
||||||
|
vi.spyOn(nodeFs.promises, 'readFile').mockImplementation(
|
||||||
|
((file: unknown, ...args: unknown[]) => {
|
||||||
|
if (!intercepted && String(file) === configPath) {
|
||||||
|
intercepted = true;
|
||||||
|
return readDeferred.promise as never;
|
||||||
|
}
|
||||||
|
return realReadFile(file as never, ...(args as never[])) as never;
|
||||||
|
}) as never
|
||||||
|
);
|
||||||
|
|
||||||
|
const reader = new TeamConfigReader();
|
||||||
|
const staleVerified = reader.getConfig(teamName);
|
||||||
|
await vi.waitFor(() => expect(intercepted).toBe(true));
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Fresh Verified Prime',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await TeamConfigReader.primeConfig(teamName, {
|
||||||
|
name: 'Fresh Verified Prime',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect((await reader.getConfig(teamName))?.name).toBe('Fresh Verified Prime');
|
||||||
|
|
||||||
|
readDeferred.resolve(staleRaw);
|
||||||
|
expect((await staleVerified)?.name).toBe('Stale Verified Read');
|
||||||
|
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Fresh Verified Prime');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not let stale in-flight snapshot read failures invalidate a primed config cache', async () => {
|
it('does not let stale in-flight snapshot read failures invalidate a primed config cache', async () => {
|
||||||
const teamName = 'stale-read-failure-prime-team';
|
const teamName = 'stale-read-failure-prime-team';
|
||||||
const teamDir = path.join(tempDir, teamName);
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { encodePath, setClaudeBasePathOverride } from '../../../../src/main/util
|
||||||
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
|
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
|
||||||
import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils';
|
import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils';
|
||||||
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
|
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
|
||||||
|
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
|
||||||
import type { TeamMetaFile } from '../../../../src/main/services/team/TeamMetaStore';
|
import type { TeamMetaFile } from '../../../../src/main/services/team/TeamMetaStore';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -237,6 +238,138 @@ 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps team deletion mutations on verified config reads', async () => {
|
||||||
|
const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-data-delete-verified-'));
|
||||||
|
tempPaths.push(claudeRoot);
|
||||||
|
setClaudeBasePathOverride(claudeRoot);
|
||||||
|
await fs.mkdir(path.join(claudeRoot, 'teams', 'my-team'), { recursive: true });
|
||||||
|
|
||||||
|
const getConfig = vi.fn(async () => ({
|
||||||
|
name: 'My team',
|
||||||
|
members: [],
|
||||||
|
}));
|
||||||
|
const getConfigSnapshot = vi.fn(async () => {
|
||||||
|
throw new Error('snapshot config read should not be used for team deletion');
|
||||||
|
});
|
||||||
|
const service = new TeamDataService({
|
||||||
|
listTeams: vi.fn(),
|
||||||
|
getConfig,
|
||||||
|
getConfigSnapshot,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await service.deleteTeam('my-team');
|
||||||
|
|
||||||
|
const written = JSON.parse(
|
||||||
|
await fs.readFile(path.join(claudeRoot, 'teams', 'my-team', 'config.json'), 'utf8')
|
||||||
|
) as TeamConfig;
|
||||||
|
expect(written.deletedAt).toBeTruthy();
|
||||||
|
expect(getConfig).toHaveBeenCalledWith('my-team');
|
||||||
|
expect(getConfigSnapshot).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('TeamDataService draft metadata', () => {
|
describe('TeamDataService draft metadata', () => {
|
||||||
it('round-trips create config metadata through getSavedRequest', async () => {
|
it('round-trips create config metadata through getSavedRequest', async () => {
|
||||||
const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-data-saved-request-'));
|
const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-data-saved-request-'));
|
||||||
|
|
@ -244,6 +377,7 @@ describe('TeamDataService draft metadata', () => {
|
||||||
setClaudeBasePathOverride(claudeRoot);
|
setClaudeBasePathOverride(claudeRoot);
|
||||||
|
|
||||||
const service = new TeamDataService();
|
const service = new TeamDataService();
|
||||||
|
const listCacheInvalidateSpy = vi.spyOn(TeamConfigReader, 'invalidateListTeamsCache');
|
||||||
await service.createTeamConfig({
|
await service.createTeamConfig({
|
||||||
teamName: 'draft-team',
|
teamName: 'draft-team',
|
||||||
displayName: 'Draft Team',
|
displayName: 'Draft Team',
|
||||||
|
|
@ -271,6 +405,7 @@ describe('TeamDataService draft metadata', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
expect(listCacheInvalidateSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
await expect(service.getSavedRequest('missing-team')).resolves.toBeNull();
|
await expect(service.getSavedRequest('missing-team')).resolves.toBeNull();
|
||||||
await expect(service.getSavedRequest('draft-team')).resolves.toMatchObject({
|
await expect(service.getSavedRequest('draft-team')).resolves.toMatchObject({
|
||||||
|
|
@ -1264,15 +1399,20 @@ describe('TeamDataService', () => {
|
||||||
|
|
||||||
it('includes projectPath from config when creating a task', async () => {
|
it('includes projectPath from config when creating a task', async () => {
|
||||||
const createTaskMock = vi.fn((task) => task);
|
const createTaskMock = vi.fn((task) => task);
|
||||||
|
const getConfig = vi.fn(async () => {
|
||||||
|
throw new Error('verified config read should not be used for task enrichment');
|
||||||
|
});
|
||||||
|
const getConfigSnapshot = vi.fn(async () => ({
|
||||||
|
name: 'My team',
|
||||||
|
members: [],
|
||||||
|
projectPath: '/Users/dev/my-project',
|
||||||
|
}));
|
||||||
|
|
||||||
const service = new TeamDataService(
|
const service = new TeamDataService(
|
||||||
{
|
{
|
||||||
listTeams: vi.fn(),
|
listTeams: vi.fn(),
|
||||||
getConfig: vi.fn(async () => ({
|
getConfig,
|
||||||
name: 'My team',
|
getConfigSnapshot,
|
||||||
members: [],
|
|
||||||
projectPath: '/Users/dev/my-project',
|
|
||||||
})),
|
|
||||||
} as never,
|
} as never,
|
||||||
{
|
{
|
||||||
getNextTaskId: vi.fn(async () => '1'),
|
getNextTaskId: vi.fn(async () => '1'),
|
||||||
|
|
@ -1311,6 +1451,8 @@ describe('TeamDataService', () => {
|
||||||
expect(createTaskMock).toHaveBeenCalledWith(
|
expect(createTaskMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ projectPath: '/Users/dev/my-project' })
|
expect.objectContaining({ projectPath: '/Users/dev/my-project' })
|
||||||
);
|
);
|
||||||
|
expect(getConfigSnapshot).toHaveBeenCalledWith('my-team');
|
||||||
|
expect(getConfig).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns lightweight notification context from config without hydrating team data', async () => {
|
it('returns lightweight notification context from config without hydrating team data', async () => {
|
||||||
|
|
@ -1319,11 +1461,17 @@ describe('TeamDataService', () => {
|
||||||
projectPath: '/Users/dev/my-project',
|
projectPath: '/Users/dev/my-project',
|
||||||
members: [],
|
members: [],
|
||||||
}));
|
}));
|
||||||
|
const getConfigSnapshot = vi.fn(async () => ({
|
||||||
|
name: 'My Team',
|
||||||
|
projectPath: '/Users/dev/my-project',
|
||||||
|
members: [],
|
||||||
|
}));
|
||||||
|
|
||||||
const service = new TeamDataService(
|
const service = new TeamDataService(
|
||||||
{
|
{
|
||||||
listTeams: vi.fn(),
|
listTeams: vi.fn(),
|
||||||
getConfig,
|
getConfig,
|
||||||
|
getConfigSnapshot,
|
||||||
} as never,
|
} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
|
@ -1343,7 +1491,8 @@ describe('TeamDataService', () => {
|
||||||
displayName: 'My Team',
|
displayName: 'My Team',
|
||||||
projectPath: '/Users/dev/my-project',
|
projectPath: '/Users/dev/my-project',
|
||||||
});
|
});
|
||||||
expect(getConfig).toHaveBeenCalledWith('my-team');
|
expect(getConfigSnapshot).toHaveBeenCalledWith('my-team');
|
||||||
|
expect(getConfig).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates task with status pending when startImmediately is false', async () => {
|
it('creates task with status pending when startImmediately is false', async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const hoisted = vi.hoisted(() => {
|
const hoisted = vi.hoisted(() => {
|
||||||
|
const skipResponsesForOps = new Set<string>();
|
||||||
const workers: Array<{
|
const workers: Array<{
|
||||||
messages: unknown[];
|
messages: unknown[];
|
||||||
handlers: Map<string, (value: unknown) => void>;
|
handlers: Map<string, (value: unknown) => void>;
|
||||||
|
|
@ -15,6 +16,7 @@ const hoisted = vi.hoisted(() => {
|
||||||
postMessage(message: unknown) {
|
postMessage(message: unknown) {
|
||||||
worker.messages.push(message);
|
worker.messages.push(message);
|
||||||
const request = message as { id: string; op: string; payload?: { teamName?: string } };
|
const request = message as { id: string; op: string; payload?: { teamName?: string } };
|
||||||
|
if (skipResponsesForOps.has(request.op)) return;
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
const handler = worker.handlers.get('message');
|
const handler = worker.handlers.get('message');
|
||||||
if (!handler) return;
|
if (!handler) return;
|
||||||
|
|
@ -24,6 +26,8 @@ const hoisted = vi.hoisted(() => {
|
||||||
result:
|
result:
|
||||||
request.op === 'getTeamData'
|
request.op === 'getTeamData'
|
||||||
? { teamName: request.payload?.teamName, config: { name: 'Team' } }
|
? { teamName: request.payload?.teamName, config: { name: 'Team' } }
|
||||||
|
: request.op === 'getMessagesPage'
|
||||||
|
? { messages: [], nextCursor: null, hasMore: false, feedRevision: 'rev-1' }
|
||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -39,6 +43,7 @@ const hoisted = vi.hoisted(() => {
|
||||||
return {
|
return {
|
||||||
workers,
|
workers,
|
||||||
createMockWorker,
|
createMockWorker,
|
||||||
|
skipResponsesForOps,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -61,7 +66,9 @@ describe('TeamDataWorkerClient', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
hoisted.workers.length = 0;
|
hoisted.workers.length = 0;
|
||||||
|
hoisted.skipResponsesForOps.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deduplicates concurrent getTeamData calls for the same team', async () => {
|
it('deduplicates concurrent getTeamData calls for the same team', async () => {
|
||||||
|
|
@ -86,6 +93,25 @@ describe('TeamDataWorkerClient', () => {
|
||||||
client.dispose();
|
client.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not queue warmup behind an already running worker', async () => {
|
||||||
|
const { TeamDataWorkerClient } = await import(
|
||||||
|
'../../../../src/main/services/team/TeamDataWorkerClient'
|
||||||
|
);
|
||||||
|
const client = new TeamDataWorkerClient();
|
||||||
|
|
||||||
|
await client.getTeamData('my-team');
|
||||||
|
await client.prewarm();
|
||||||
|
|
||||||
|
expect(hoisted.workers).toHaveLength(1);
|
||||||
|
expect(hoisted.workers[0].messages).toHaveLength(1);
|
||||||
|
expect(hoisted.workers[0].messages[0]).toMatchObject({
|
||||||
|
op: 'getTeamData',
|
||||||
|
payload: { teamName: 'my-team' },
|
||||||
|
});
|
||||||
|
|
||||||
|
client.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it('sends best-effort team config invalidation to the worker', async () => {
|
it('sends best-effort team config invalidation to the worker', async () => {
|
||||||
const { TeamDataWorkerClient } = await import(
|
const { TeamDataWorkerClient } = await import(
|
||||||
'../../../../src/main/services/team/TeamDataWorkerClient'
|
'../../../../src/main/services/team/TeamDataWorkerClient'
|
||||||
|
|
@ -107,6 +133,71 @@ describe('TeamDataWorkerClient', () => {
|
||||||
client.dispose();
|
client.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deduplicates concurrent getMessagesPage calls with the same page key', async () => {
|
||||||
|
const { TeamDataWorkerClient } = await import(
|
||||||
|
'../../../../src/main/services/team/TeamDataWorkerClient'
|
||||||
|
);
|
||||||
|
const client = new TeamDataWorkerClient();
|
||||||
|
|
||||||
|
const [first, second] = await Promise.all([
|
||||||
|
client.getMessagesPage('my-team', { cursor: null, limit: 50 }),
|
||||||
|
client.getMessagesPage('my-team', { cursor: null, limit: 50 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(first).toEqual(second);
|
||||||
|
expect(hoisted.workers).toHaveLength(1);
|
||||||
|
expect(hoisted.workers[0].messages).toHaveLength(1);
|
||||||
|
expect(hoisted.workers[0].messages[0]).toMatchObject({
|
||||||
|
op: 'getMessagesPage',
|
||||||
|
payload: { teamName: 'my-team', options: { cursor: null, limit: 50 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
client.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends best-effort message feed invalidation to the worker', async () => {
|
||||||
|
const { TeamDataWorkerClient } = await import(
|
||||||
|
'../../../../src/main/services/team/TeamDataWorkerClient'
|
||||||
|
);
|
||||||
|
const client = new TeamDataWorkerClient();
|
||||||
|
await client.getTeamData('my-team');
|
||||||
|
hoisted.workers[0].messages.length = 0;
|
||||||
|
|
||||||
|
client.invalidateTeamMessageFeed('my-team');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(hoisted.workers).toHaveLength(1);
|
||||||
|
expect(hoisted.workers[0].messages).toHaveLength(1);
|
||||||
|
expect(hoisted.workers[0].messages[0]).toMatchObject({
|
||||||
|
op: 'invalidateTeamMessageFeed',
|
||||||
|
payload: { teamName: 'my-team' },
|
||||||
|
});
|
||||||
|
|
||||||
|
client.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears in-flight getMessagesPage dedupe when invalidating message feed', async () => {
|
||||||
|
const { TeamDataWorkerClient } = await import(
|
||||||
|
'../../../../src/main/services/team/TeamDataWorkerClient'
|
||||||
|
);
|
||||||
|
const client = new TeamDataWorkerClient();
|
||||||
|
|
||||||
|
const first = client.getMessagesPage('my-team', { cursor: null, limit: 50 });
|
||||||
|
client.invalidateTeamMessageFeed('my-team');
|
||||||
|
const second = client.getMessagesPage('my-team', { cursor: null, limit: 50 });
|
||||||
|
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
|
||||||
|
expect(hoisted.workers).toHaveLength(1);
|
||||||
|
expect(hoisted.workers[0].messages.map((message) => (message as { op: string }).op)).toEqual([
|
||||||
|
'getMessagesPage',
|
||||||
|
'invalidateTeamMessageFeed',
|
||||||
|
'getMessagesPage',
|
||||||
|
]);
|
||||||
|
|
||||||
|
client.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it('clears in-flight getTeamData dedupe when invalidating team config', async () => {
|
it('clears in-flight getTeamData dedupe when invalidating team config', async () => {
|
||||||
const { TeamDataWorkerClient } = await import(
|
const { TeamDataWorkerClient } = await import(
|
||||||
'../../../../src/main/services/team/TeamDataWorkerClient'
|
'../../../../src/main/services/team/TeamDataWorkerClient'
|
||||||
|
|
@ -140,4 +231,23 @@ describe('TeamDataWorkerClient', () => {
|
||||||
|
|
||||||
expect(hoisted.workers).toHaveLength(0);
|
expect(hoisted.workers).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not attach a timeout that can kill the worker for best-effort invalidation', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { TeamDataWorkerClient } = await import(
|
||||||
|
'../../../../src/main/services/team/TeamDataWorkerClient'
|
||||||
|
);
|
||||||
|
const client = new TeamDataWorkerClient();
|
||||||
|
await client.getTeamData('my-team');
|
||||||
|
hoisted.workers[0].messages.length = 0;
|
||||||
|
hoisted.skipResponsesForOps.add('invalidateTeamMessageFeed');
|
||||||
|
|
||||||
|
client.invalidateTeamMessageFeed('my-team');
|
||||||
|
await vi.advanceTimersByTimeAsync(31_000);
|
||||||
|
|
||||||
|
expect(hoisted.workers[0].messages).toHaveLength(1);
|
||||||
|
expect(hoisted.workers[0].terminate).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
client.dispose();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { existsSync } from 'fs';
|
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
@ -12,17 +11,13 @@ interface WorkerResponse {
|
||||||
id: string;
|
id: string;
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
result?: unknown;
|
result?: unknown;
|
||||||
|
diag?: unknown;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let bundledWorkerPathPromise: Promise<string> | null = null;
|
let bundledWorkerPathPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
async function getWorkerPath(): Promise<string> {
|
async function getWorkerPath(): Promise<string> {
|
||||||
const builtWorkerPath = path.join(process.cwd(), 'dist-electron', 'main', 'team-fs-worker.cjs');
|
|
||||||
if (existsSync(builtWorkerPath)) {
|
|
||||||
return builtWorkerPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
bundledWorkerPathPromise ??= bundleWorkerForTests();
|
bundledWorkerPathPromise ??= bundleWorkerForTests();
|
||||||
return bundledWorkerPathPromise;
|
return bundledWorkerPathPromise;
|
||||||
}
|
}
|
||||||
|
|
@ -50,7 +45,11 @@ function createWorker(workerPath: string): Worker {
|
||||||
return new Worker(workerPath);
|
return new Worker(workerPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
function callListTeams(worker: Worker, teamsDir: string): Promise<unknown[]> {
|
function callWorker(
|
||||||
|
worker: Worker,
|
||||||
|
op: string,
|
||||||
|
payload: Record<string, unknown> = {}
|
||||||
|
): Promise<{ result: unknown; diag?: unknown }> {
|
||||||
const requestId = `req-${Date.now()}`;
|
const requestId = `req-${Date.now()}`;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
|
|
@ -78,29 +77,56 @@ function callListTeams(worker: Worker, teamsDir: string): Promise<unknown[]> {
|
||||||
reject(new Error(message.error || 'team-fs-worker returned an unknown error'));
|
reject(new Error(message.error || 'team-fs-worker returned an unknown error'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolve(Array.isArray(message.result) ? message.result : []);
|
resolve({ result: message.result, diag: message.diag });
|
||||||
};
|
};
|
||||||
|
|
||||||
worker.on('message', onMessage);
|
worker.on('message', onMessage);
|
||||||
worker.on('error', onError);
|
worker.on('error', onError);
|
||||||
worker.postMessage({
|
worker.postMessage({ id: requestId, op, payload });
|
||||||
id: requestId,
|
|
||||||
op: 'listTeams',
|
|
||||||
payload: {
|
|
||||||
teamsDir,
|
|
||||||
largeConfigBytes: 8 * 1024,
|
|
||||||
configHeadBytes: 4 * 1024,
|
|
||||||
maxConfigBytes: 256 * 1024,
|
|
||||||
maxConfigReadMs: 5_000,
|
|
||||||
maxMembersMetaBytes: 256 * 1024,
|
|
||||||
maxSessionHistoryInSummary: 10,
|
|
||||||
maxProjectPathHistoryInSummary: 10,
|
|
||||||
concurrency: 2,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function callListTeams(worker: Worker, teamsDir: string): Promise<{
|
||||||
|
teams: unknown[];
|
||||||
|
diag?: Record<string, unknown>;
|
||||||
|
}> {
|
||||||
|
const { result, diag } = await callWorker(worker, 'listTeams', {
|
||||||
|
teamsDir,
|
||||||
|
largeConfigBytes: 8 * 1024,
|
||||||
|
configHeadBytes: 4 * 1024,
|
||||||
|
maxConfigBytes: 256 * 1024,
|
||||||
|
maxConfigReadMs: 5_000,
|
||||||
|
maxMembersMetaBytes: 256 * 1024,
|
||||||
|
maxSessionHistoryInSummary: 10,
|
||||||
|
maxProjectPathHistoryInSummary: 10,
|
||||||
|
concurrency: 2,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
teams: Array.isArray(result) ? result : [],
|
||||||
|
diag: diag && typeof diag === 'object' ? (diag as Record<string, unknown>) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callGetAllTasks(worker: Worker, tasksBase: string): Promise<{
|
||||||
|
tasks: unknown[];
|
||||||
|
diag?: Record<string, unknown>;
|
||||||
|
}> {
|
||||||
|
const { result, diag } = await callWorker(worker, 'getAllTasks', {
|
||||||
|
tasksBase,
|
||||||
|
maxTaskBytes: 256 * 1024,
|
||||||
|
maxTaskReadMs: 5_000,
|
||||||
|
concurrency: 2,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
tasks: Array.isArray(result) ? result : [],
|
||||||
|
diag: diag && typeof diag === 'object' ? (diag as Record<string, unknown>) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callWarmup(worker: Worker): Promise<void> {
|
||||||
|
await callWorker(worker, 'warmup');
|
||||||
|
}
|
||||||
|
|
||||||
describe('team-fs-worker integration', () => {
|
describe('team-fs-worker integration', () => {
|
||||||
let tempDir = '';
|
let tempDir = '';
|
||||||
|
|
||||||
|
|
@ -189,7 +215,7 @@ describe('team-fs-worker integration', () => {
|
||||||
|
|
||||||
const worker = createWorker(workerPath);
|
const worker = createWorker(workerPath);
|
||||||
try {
|
try {
|
||||||
const teams = (await callListTeams(worker, tempDir)) as Array<Record<string, unknown>>;
|
const { teams } = await callListTeams(worker, tempDir);
|
||||||
expect(teams).toHaveLength(1);
|
expect(teams).toHaveLength(1);
|
||||||
expect(teams[0]).toMatchObject({
|
expect(teams[0]).toMatchObject({
|
||||||
teamName,
|
teamName,
|
||||||
|
|
@ -230,7 +256,7 @@ describe('team-fs-worker integration', () => {
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 1,
|
version: 1,
|
||||||
members: [
|
members: [
|
||||||
{ name: 'team-lead', agentType: 'team-lead' },
|
{ name: 'team-lead', agentType: 'team-lead', color: '#123456' },
|
||||||
{ name: 'alice', removedAt: Date.parse('2026-04-22T12:01:00.000Z') },
|
{ name: 'alice', removedAt: Date.parse('2026-04-22T12:01:00.000Z') },
|
||||||
{ name: 'bob', role: 'developer' },
|
{ name: 'bob', role: 'developer' },
|
||||||
],
|
],
|
||||||
|
|
@ -240,15 +266,163 @@ describe('team-fs-worker integration', () => {
|
||||||
|
|
||||||
const worker = createWorker(workerPath);
|
const worker = createWorker(workerPath);
|
||||||
try {
|
try {
|
||||||
const teams = (await callListTeams(worker, tempDir)) as Array<Record<string, unknown>>;
|
const { teams } = await callListTeams(worker, tempDir);
|
||||||
expect(teams).toHaveLength(1);
|
expect(teams).toHaveLength(1);
|
||||||
expect(teams[0]).toMatchObject({
|
expect(teams[0]).toMatchObject({
|
||||||
teamName,
|
teamName,
|
||||||
displayName: 'Draft Worker Team',
|
displayName: 'Draft Worker Team',
|
||||||
memberCount: 1,
|
memberCount: 1,
|
||||||
|
leadName: 'team-lead',
|
||||||
|
leadColor: '#123456',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await worker.terminate();
|
await worker.terminate();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prewarms and reuses unchanged team summaries by fingerprint', async () => {
|
||||||
|
const workerPath = await getWorkerPath();
|
||||||
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-'));
|
||||||
|
const teamName = 'cached-worker-team';
|
||||||
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
await fs.mkdir(path.join(teamDir, 'inboxes'), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(teamDir, 'config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Cached Worker Team',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(teamDir, 'members.meta.json'),
|
||||||
|
JSON.stringify({ version: 1, members: [{ name: 'alice' }] }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const worker = createWorker(workerPath);
|
||||||
|
try {
|
||||||
|
await callWarmup(worker);
|
||||||
|
const first = await callListTeams(worker, tempDir);
|
||||||
|
expect(first.teams[0]).toMatchObject({ teamName, memberCount: 1 });
|
||||||
|
expect(first.diag?.cacheMisses).toBe(1);
|
||||||
|
|
||||||
|
const second = await callListTeams(worker, tempDir);
|
||||||
|
expect(second.teams[0]).toMatchObject({ teamName, memberCount: 1 });
|
||||||
|
expect(second.diag?.cacheHits).toBe(1);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(teamDir, 'members.meta.json'),
|
||||||
|
JSON.stringify({ version: 1, members: [{ name: 'alice' }, { name: 'bob' }] }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const changed = await callListTeams(worker, tempDir);
|
||||||
|
expect(changed.teams[0]).toMatchObject({ teamName, memberCount: 2 });
|
||||||
|
expect(changed.diag?.cacheMisses).toBe(1);
|
||||||
|
} finally {
|
||||||
|
await worker.terminate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cache pending launch summaries because liveness can change without file writes', async () => {
|
||||||
|
const workerPath = await getWorkerPath();
|
||||||
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-'));
|
||||||
|
const teamName = 'pending-launch-team';
|
||||||
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(teamDir, 'config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Pending Launch Team',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(teamDir, 'launch-summary.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
teamName,
|
||||||
|
updatedAt: '2026-05-02T12:00:00.000Z',
|
||||||
|
teamLaunchState: 'partial_pending',
|
||||||
|
expectedMemberCount: 1,
|
||||||
|
pendingCount: 1,
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const worker = createWorker(workerPath);
|
||||||
|
try {
|
||||||
|
const first = await callListTeams(worker, tempDir);
|
||||||
|
expect(first.teams[0]).toMatchObject({
|
||||||
|
teamName,
|
||||||
|
teamLaunchState: 'partial_pending',
|
||||||
|
pendingCount: 1,
|
||||||
|
});
|
||||||
|
expect(first.diag?.cacheMisses).toBe(1);
|
||||||
|
expect(first.diag?.cacheWriteSkips).toBe(1);
|
||||||
|
|
||||||
|
const second = await callListTeams(worker, tempDir);
|
||||||
|
expect(second.teams[0]).toMatchObject({
|
||||||
|
teamName,
|
||||||
|
teamLaunchState: 'partial_pending',
|
||||||
|
pendingCount: 1,
|
||||||
|
});
|
||||||
|
expect(second.diag?.cacheHits).toBe(0);
|
||||||
|
expect(second.diag?.cacheMisses).toBe(1);
|
||||||
|
expect(second.diag?.cacheWriteSkips).toBe(1);
|
||||||
|
} finally {
|
||||||
|
await worker.terminate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses unchanged parsed tasks and rereads changed task files by fingerprint', async () => {
|
||||||
|
const workerPath = await getWorkerPath();
|
||||||
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-'));
|
||||||
|
const tasksBase = path.join(tempDir, 'tasks');
|
||||||
|
const teamName = 'task-cache-team';
|
||||||
|
const tasksDir = path.join(tasksBase, teamName);
|
||||||
|
await fs.mkdir(tasksDir, { recursive: true });
|
||||||
|
const taskPath = path.join(tasksDir, '1.json');
|
||||||
|
await fs.writeFile(
|
||||||
|
taskPath,
|
||||||
|
JSON.stringify({
|
||||||
|
id: '1',
|
||||||
|
subject: 'First subject',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: '2026-05-02T12:00:00.000Z',
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const worker = createWorker(workerPath);
|
||||||
|
try {
|
||||||
|
const first = await callGetAllTasks(worker, tasksBase);
|
||||||
|
expect(first.tasks[0]).toMatchObject({ teamName, subject: 'First subject' });
|
||||||
|
expect(first.diag?.cacheMisses).toBe(1);
|
||||||
|
|
||||||
|
const second = await callGetAllTasks(worker, tasksBase);
|
||||||
|
expect(second.tasks[0]).toMatchObject({ teamName, subject: 'First subject' });
|
||||||
|
expect(second.diag?.cacheHits).toBe(1);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
taskPath,
|
||||||
|
JSON.stringify({
|
||||||
|
id: '1',
|
||||||
|
subject: 'Changed subject with a different size',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: '2026-05-02T12:00:00.000Z',
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const changed = await callGetAllTasks(worker, tasksBase);
|
||||||
|
expect(changed.tasks[0]).toMatchObject({
|
||||||
|
teamName,
|
||||||
|
subject: 'Changed subject with a different size',
|
||||||
|
});
|
||||||
|
expect(changed.diag?.cacheMisses).toBe(1);
|
||||||
|
} finally {
|
||||||
|
await worker.terminate();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
152
test/main/services/team/TeamFsWorkerClient.test.ts
Normal file
152
test/main/services/team/TeamFsWorkerClient.test.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
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>;
|
||||||
|
postMessage: (message: unknown) => void;
|
||||||
|
on: (event: string, handler: (value: unknown) => void) => void;
|
||||||
|
terminate: ReturnType<typeof vi.fn>;
|
||||||
|
}> = [];
|
||||||
|
const createMockWorker = vi.fn().mockImplementation(() => {
|
||||||
|
const worker = {
|
||||||
|
messages: [] as unknown[],
|
||||||
|
handlers: new Map<string, (value: unknown) => void>(),
|
||||||
|
postMessage(message: unknown) {
|
||||||
|
worker.messages.push(message);
|
||||||
|
const request = message as { id: string; op: string };
|
||||||
|
if (skipResponsesForOps.has(request.op)) return;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const handler = worker.handlers.get('message');
|
||||||
|
if (!handler) return;
|
||||||
|
handler({
|
||||||
|
id: request.id,
|
||||||
|
ok: true,
|
||||||
|
result: request.op === 'listTeams' || request.op === 'getAllTasks' ? [] : null,
|
||||||
|
diag: { op: request.op, totalMs: 0 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
on(event: string, handler: (value: unknown) => void) {
|
||||||
|
worker.handlers.set(event, handler);
|
||||||
|
},
|
||||||
|
terminate: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
workers.push(worker);
|
||||||
|
return worker;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
workers,
|
||||||
|
createMockWorker,
|
||||||
|
skipResponsesForOps,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('node:fs', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
existsSync: vi.fn(() => true),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('node:worker_threads', () => ({
|
||||||
|
Worker: hoisted.createMockWorker,
|
||||||
|
default: {
|
||||||
|
Worker: hoisted.createMockWorker,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('TeamFsWorkerClient', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
hoisted.workers.length = 0;
|
||||||
|
hoisted.skipResponsesForOps.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prewarms the worker without running a scan', async () => {
|
||||||
|
const { TeamFsWorkerClient } = await import(
|
||||||
|
'../../../../src/main/services/team/TeamFsWorkerClient'
|
||||||
|
);
|
||||||
|
const client = new TeamFsWorkerClient();
|
||||||
|
|
||||||
|
await client.prewarm();
|
||||||
|
|
||||||
|
expect(hoisted.workers).toHaveLength(1);
|
||||||
|
expect(hoisted.workers[0].messages).toHaveLength(1);
|
||||||
|
expect(hoisted.workers[0].messages[0]).toMatchObject({
|
||||||
|
op: 'warmup',
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not queue warmup behind an already running worker', async () => {
|
||||||
|
const { TeamFsWorkerClient } = await import(
|
||||||
|
'../../../../src/main/services/team/TeamFsWorkerClient'
|
||||||
|
);
|
||||||
|
const client = new TeamFsWorkerClient();
|
||||||
|
|
||||||
|
await client.listTeams({
|
||||||
|
largeConfigBytes: 8 * 1024,
|
||||||
|
configHeadBytes: 4 * 1024,
|
||||||
|
maxConfigBytes: 256 * 1024,
|
||||||
|
maxMembersMetaBytes: 256 * 1024,
|
||||||
|
maxSessionHistoryInSummary: 10,
|
||||||
|
maxProjectPathHistoryInSummary: 10,
|
||||||
|
});
|
||||||
|
await client.prewarm();
|
||||||
|
|
||||||
|
expect(hoisted.workers).toHaveLength(1);
|
||||||
|
expect(hoisted.workers[0].messages).toHaveLength(1);
|
||||||
|
expect(hoisted.workers[0].messages[0]).toMatchObject({
|
||||||
|
op: 'listTeams',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores stale worker exit after timeout when a replacement worker owns pending work', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
hoisted.skipResponsesForOps.add('warmup');
|
||||||
|
hoisted.skipResponsesForOps.add('listTeams');
|
||||||
|
const { TeamFsWorkerClient } = await import(
|
||||||
|
'../../../../src/main/services/team/TeamFsWorkerClient'
|
||||||
|
);
|
||||||
|
const client = new TeamFsWorkerClient();
|
||||||
|
|
||||||
|
const prewarmResult = client.prewarm().catch((error: unknown) => error);
|
||||||
|
await vi.advanceTimersByTimeAsync(20_001);
|
||||||
|
const prewarmError = await prewarmResult;
|
||||||
|
expect(prewarmError).toBeInstanceOf(Error);
|
||||||
|
expect((prewarmError as Error).message).toContain('Worker call timeout');
|
||||||
|
expect(hoisted.workers).toHaveLength(1);
|
||||||
|
|
||||||
|
const listPromise = client.listTeams({
|
||||||
|
largeConfigBytes: 8 * 1024,
|
||||||
|
configHeadBytes: 4 * 1024,
|
||||||
|
maxConfigBytes: 256 * 1024,
|
||||||
|
maxMembersMetaBytes: 256 * 1024,
|
||||||
|
maxSessionHistoryInSummary: 10,
|
||||||
|
maxProjectPathHistoryInSummary: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hoisted.workers).toHaveLength(2);
|
||||||
|
const staleWorker = hoisted.workers[0];
|
||||||
|
const replacementWorker = hoisted.workers[1];
|
||||||
|
const listRequest = replacementWorker.messages[0] as { id: string };
|
||||||
|
|
||||||
|
staleWorker.handlers.get('exit')?.(1);
|
||||||
|
replacementWorker.handlers.get('message')?.({
|
||||||
|
id: listRequest.id,
|
||||||
|
ok: true,
|
||||||
|
result: [{ teamName: 'fresh-team', displayName: 'Fresh Team' }],
|
||||||
|
diag: { op: 'listTeams', totalMs: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(listPromise).resolves.toEqual({
|
||||||
|
teams: [{ teamName: 'fresh-team', displayName: 'Fresh Team' }],
|
||||||
|
diag: { op: 'listTeams', totalMs: 1 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -17,6 +17,16 @@ function makeMessage(overrides: Partial<InboxMessage> = {}): InboxMessage {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (error: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
describe('TeamMessageFeedService', () => {
|
describe('TeamMessageFeedService', () => {
|
||||||
const config: TeamConfig = {
|
const config: TeamConfig = {
|
||||||
name: 'Signal Ops 4',
|
name: 'Signal Ops 4',
|
||||||
|
|
@ -101,6 +111,75 @@ describe('TeamMessageFeedService', () => {
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deduplicates concurrent feed rebuilds for the same team', async () => {
|
||||||
|
const inboxRequest = createDeferred<InboxMessage[]>();
|
||||||
|
const getInboxMessages = vi.fn(() => inboxRequest.promise);
|
||||||
|
const service = new TeamMessageFeedService({
|
||||||
|
getConfig: vi.fn(async () => config),
|
||||||
|
getInboxMessages,
|
||||||
|
getLeadSessionMessages: vi.fn(async () => []),
|
||||||
|
getSentMessages: vi.fn(async () => []),
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = service.getFeed('signal-ops-4');
|
||||||
|
const second = service.getFeed('signal-ops-4');
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(getInboxMessages).toHaveBeenCalledTimes(1);
|
||||||
|
inboxRequest.resolve([makeMessage()]);
|
||||||
|
|
||||||
|
const [firstFeed, secondFeed] = await Promise.all([first, second]);
|
||||||
|
expect(firstFeed).toEqual(secondFeed);
|
||||||
|
expect(firstFeed.messages).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reuse or cache a stale in-flight rebuild after invalidation', async () => {
|
||||||
|
const firstInboxRequest = createDeferred<InboxMessage[]>();
|
||||||
|
const secondInboxRequest = createDeferred<InboxMessage[]>();
|
||||||
|
const getInboxMessages = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce(() => firstInboxRequest.promise)
|
||||||
|
.mockImplementationOnce(() => secondInboxRequest.promise);
|
||||||
|
const service = new TeamMessageFeedService({
|
||||||
|
getConfig: vi.fn(async () => config),
|
||||||
|
getInboxMessages,
|
||||||
|
getLeadSessionMessages: vi.fn(async () => []),
|
||||||
|
getSentMessages: vi.fn(async () => []),
|
||||||
|
});
|
||||||
|
|
||||||
|
const staleRequest = service.getFeed('signal-ops-4');
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(getInboxMessages).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
service.invalidate('signal-ops-4');
|
||||||
|
const freshRequest = service.getFeed('signal-ops-4');
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(getInboxMessages).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
secondInboxRequest.resolve([
|
||||||
|
makeMessage({
|
||||||
|
messageId: 'fresh-message',
|
||||||
|
text: 'fresh',
|
||||||
|
timestamp: '2026-04-19T18:46:45.000Z',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const freshFeed = await freshRequest;
|
||||||
|
expect(freshFeed.messages[0]?.messageId).toBe('fresh-message');
|
||||||
|
|
||||||
|
firstInboxRequest.resolve([
|
||||||
|
makeMessage({
|
||||||
|
messageId: 'stale-message',
|
||||||
|
text: 'stale',
|
||||||
|
timestamp: '2026-04-19T18:46:44.000Z',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
await staleRequest;
|
||||||
|
|
||||||
|
const cachedFeed = await service.getFeed('signal-ops-4');
|
||||||
|
expect(cachedFeed.messages[0]?.messageId).toBe('fresh-message');
|
||||||
|
expect(getInboxMessages).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('adds UI-only OpenCode bootstrap start rows for side-lane teammates', async () => {
|
it('adds UI-only OpenCode bootstrap start rows for side-lane teammates', async () => {
|
||||||
const opencodeConfig: TeamConfig = {
|
const opencodeConfig: TeamConfig = {
|
||||||
name: 'relay-works-14',
|
name: 'relay-works-14',
|
||||||
|
|
|
||||||
|
|
@ -129,8 +129,12 @@ import {
|
||||||
initializeAutoResumeService,
|
initializeAutoResumeService,
|
||||||
} from '@main/services/team/AutoResumeService';
|
} from '@main/services/team/AutoResumeService';
|
||||||
import { getTeamBootstrapStatePath } from '@main/services/team/TeamBootstrapStateReader';
|
import { getTeamBootstrapStatePath } from '@main/services/team/TeamBootstrapStateReader';
|
||||||
import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
|
import {
|
||||||
|
createPersistedLaunchSnapshot,
|
||||||
|
snapshotFromRuntimeMemberStatuses,
|
||||||
|
} from '@main/services/team/TeamLaunchStateEvaluator';
|
||||||
import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore';
|
import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore';
|
||||||
|
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||||
import {
|
import {
|
||||||
getOpenCodeLaneScopedRuntimeFilePath,
|
getOpenCodeLaneScopedRuntimeFilePath,
|
||||||
getOpenCodeRuntimeManifestPath,
|
getOpenCodeRuntimeManifestPath,
|
||||||
|
|
@ -205,6 +209,16 @@ function createPidusageStat(pid: number, memory: number) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
function writeLaunchConfig(
|
function writeLaunchConfig(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
|
|
@ -617,7 +631,541 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('member spawn status launch reads', () => {
|
||||||
|
it('coalesces concurrent active launch status reads and serves a short cached follow-up', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const teamName = 'spawn-cache-team';
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
teamName,
|
||||||
|
expectedMembers: ['alice'],
|
||||||
|
});
|
||||||
|
run.isLaunch = true;
|
||||||
|
run.progress = {
|
||||||
|
teamName,
|
||||||
|
state: 'launching',
|
||||||
|
message: 'Launching',
|
||||||
|
updatedAt: '2026-05-02T10:00:00.000Z',
|
||||||
|
};
|
||||||
|
run.onProgress = vi.fn();
|
||||||
|
(svc as any).aliveRunByTeam.set(teamName, run.runId);
|
||||||
|
(svc as any).runs.set(run.runId, run);
|
||||||
|
(svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) };
|
||||||
|
(svc as any).launchStateStore = {
|
||||||
|
read: vi.fn(async () => null),
|
||||||
|
write: vi.fn(async () => {}),
|
||||||
|
clear: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
|
||||||
|
const refreshDeferred = createDeferred<void>();
|
||||||
|
const refreshLeadInbox = vi
|
||||||
|
.spyOn(svc as any, 'refreshMemberSpawnStatusesFromLeadInbox')
|
||||||
|
.mockImplementation(async () => refreshDeferred.promise);
|
||||||
|
const auditStatuses = vi
|
||||||
|
.spyOn(svc as any, 'maybeAuditMemberSpawnStatuses')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
const persistSnapshot = vi
|
||||||
|
.spyOn(svc as any, 'persistLaunchStateSnapshot')
|
||||||
|
.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const first = svc.getMemberSpawnStatuses(teamName);
|
||||||
|
const second = svc.getMemberSpawnStatuses(teamName);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(refreshLeadInbox).toHaveBeenCalledTimes(1);
|
||||||
|
refreshDeferred.resolve();
|
||||||
|
const [firstSnapshot, secondSnapshot] = await Promise.all([first, second]);
|
||||||
|
|
||||||
|
expect(firstSnapshot).toEqual(secondSnapshot);
|
||||||
|
expect(auditStatuses).toHaveBeenCalledTimes(1);
|
||||||
|
expect(persistSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
||||||
|
expect(refreshLeadInbox).toHaveBeenCalledTimes(1);
|
||||||
|
expect(auditStatuses).toHaveBeenCalledTimes(1);
|
||||||
|
expect(persistSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates the short status cache when a real member-spawn change is emitted', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const teamName = 'spawn-cache-invalidated-team';
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
teamName,
|
||||||
|
expectedMembers: ['alice'],
|
||||||
|
});
|
||||||
|
run.isLaunch = true;
|
||||||
|
run.progress = {
|
||||||
|
teamName,
|
||||||
|
state: 'launching',
|
||||||
|
message: 'Launching',
|
||||||
|
updatedAt: '2026-05-02T10:00:00.000Z',
|
||||||
|
};
|
||||||
|
run.onProgress = vi.fn();
|
||||||
|
(svc as any).aliveRunByTeam.set(teamName, run.runId);
|
||||||
|
(svc as any).runs.set(run.runId, run);
|
||||||
|
(svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) };
|
||||||
|
(svc as any).launchStateStore = {
|
||||||
|
read: vi.fn(async () => null),
|
||||||
|
write: vi.fn(async () => {}),
|
||||||
|
clear: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
|
||||||
|
const refreshLeadInbox = vi
|
||||||
|
.spyOn(svc as any, 'refreshMemberSpawnStatusesFromLeadInbox')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(svc as any, 'maybeAuditMemberSpawnStatuses').mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(svc as any, 'persistLaunchStateSnapshot').mockResolvedValue(null);
|
||||||
|
|
||||||
|
await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
expect(refreshLeadInbox).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
(svc as any).setMemberSpawnStatus(
|
||||||
|
run,
|
||||||
|
'alice',
|
||||||
|
'online',
|
||||||
|
undefined,
|
||||||
|
'heartbeat',
|
||||||
|
'2026-05-02T10:00:05.000Z'
|
||||||
|
);
|
||||||
|
await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
||||||
|
expect(refreshLeadInbox).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries the owner status request when a member-spawn change lands while it is building', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const teamName = 'spawn-cache-owner-retry-team';
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
teamName,
|
||||||
|
expectedMembers: ['alice'],
|
||||||
|
});
|
||||||
|
run.isLaunch = true;
|
||||||
|
run.progress = {
|
||||||
|
teamName,
|
||||||
|
state: 'launching',
|
||||||
|
message: 'Launching',
|
||||||
|
updatedAt: '2026-05-02T10:00:00.000Z',
|
||||||
|
};
|
||||||
|
run.onProgress = vi.fn();
|
||||||
|
(svc as any).aliveRunByTeam.set(teamName, run.runId);
|
||||||
|
(svc as any).runs.set(run.runId, run);
|
||||||
|
(svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) };
|
||||||
|
(svc as any).launchStateStore = {
|
||||||
|
read: vi.fn(async () => null),
|
||||||
|
write: vi.fn(async () => {}),
|
||||||
|
clear: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
|
||||||
|
const firstRefresh = createDeferred<void>();
|
||||||
|
const refreshLeadInbox = vi
|
||||||
|
.spyOn(svc as any, 'refreshMemberSpawnStatusesFromLeadInbox')
|
||||||
|
.mockImplementationOnce(async () => firstRefresh.promise)
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(svc as any, 'maybeAuditMemberSpawnStatuses').mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(svc as any, 'persistLaunchStateSnapshot').mockResolvedValue(null);
|
||||||
|
|
||||||
|
const pending = svc.getMemberSpawnStatuses(teamName);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(refreshLeadInbox).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
(svc as any).setMemberSpawnStatus(
|
||||||
|
run,
|
||||||
|
'alice',
|
||||||
|
'online',
|
||||||
|
undefined,
|
||||||
|
'heartbeat',
|
||||||
|
'2026-05-02T10:00:05.000Z'
|
||||||
|
);
|
||||||
|
firstRefresh.resolve();
|
||||||
|
const result = await pending;
|
||||||
|
|
||||||
|
expect(refreshLeadInbox).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result.statuses.alice).toMatchObject({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('launch-state no-op persistence guard', () => {
|
||||||
|
it('does not rewrite launch-state or invalidate runtime cache for a recent semantic no-op', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-05-02T10:00:05.000Z'));
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const teamName = 'launch-state-noop-team';
|
||||||
|
const status = createMemberSpawnStatusEntry({
|
||||||
|
updatedAt: '2026-05-02T10:00:00.000Z',
|
||||||
|
firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
const previousSnapshot = snapshotFromRuntimeMemberStatuses({
|
||||||
|
teamName,
|
||||||
|
expectedMembers: ['alice'],
|
||||||
|
launchPhase: 'active',
|
||||||
|
updatedAt: '2026-05-02T10:00:02.000Z',
|
||||||
|
statuses: { alice: status as any },
|
||||||
|
});
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
teamName,
|
||||||
|
expectedMembers: ['alice'],
|
||||||
|
memberSpawnStatuses: new Map([['alice', status]]),
|
||||||
|
});
|
||||||
|
run.isLaunch = true;
|
||||||
|
(svc as any).launchStateStore = {
|
||||||
|
read: vi.fn(async () => previousSnapshot),
|
||||||
|
write: vi.fn(async () => {}),
|
||||||
|
clear: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
(svc as any).launchStateWrittenRunIdByTeam.set(teamName, run.runId);
|
||||||
|
(svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) };
|
||||||
|
const invalidateRuntime = vi.spyOn(svc as any, 'invalidateRuntimeSnapshotCaches');
|
||||||
|
|
||||||
|
const result = await (svc as any).persistLaunchStateSnapshotNow(run, 'active');
|
||||||
|
|
||||||
|
expect(result).toBe(previousSnapshot);
|
||||||
|
expect((svc as any).launchStateStore.write).not.toHaveBeenCalled();
|
||||||
|
expect(invalidateRuntime).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a bounded launch-state heartbeat for unchanged active snapshots', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-05-02T10:00:20.000Z'));
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const teamName = 'launch-state-heartbeat-team';
|
||||||
|
const status = createMemberSpawnStatusEntry({
|
||||||
|
updatedAt: '2026-05-02T10:00:00.000Z',
|
||||||
|
firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
const previousSnapshot = snapshotFromRuntimeMemberStatuses({
|
||||||
|
teamName,
|
||||||
|
expectedMembers: ['alice'],
|
||||||
|
launchPhase: 'active',
|
||||||
|
updatedAt: '2026-05-02T10:00:00.000Z',
|
||||||
|
statuses: { alice: status as any },
|
||||||
|
});
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
teamName,
|
||||||
|
expectedMembers: ['alice'],
|
||||||
|
memberSpawnStatuses: new Map([['alice', status]]),
|
||||||
|
});
|
||||||
|
run.isLaunch = true;
|
||||||
|
(svc as any).launchStateStore = {
|
||||||
|
read: vi.fn(async () => previousSnapshot),
|
||||||
|
write: vi.fn(async () => {}),
|
||||||
|
clear: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
(svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) };
|
||||||
|
const invalidateRuntime = vi.spyOn(svc as any, 'invalidateRuntimeSnapshotCaches');
|
||||||
|
|
||||||
|
const result = await (svc as any).persistLaunchStateSnapshotNow(run, 'active');
|
||||||
|
|
||||||
|
expect(result.updatedAt).toBe('2026-05-02T10:00:20.000Z');
|
||||||
|
expect((svc as any).launchStateStore.write).toHaveBeenCalledTimes(1);
|
||||||
|
expect(invalidateRuntime).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not skip the first service-owned launch-state write for an existing snapshot', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-05-02T10:00:05.000Z'));
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const teamName = 'launch-state-first-write-team';
|
||||||
|
const status = createMemberSpawnStatusEntry({
|
||||||
|
updatedAt: '2026-05-02T10:00:00.000Z',
|
||||||
|
firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
const previousSnapshot = snapshotFromRuntimeMemberStatuses({
|
||||||
|
teamName,
|
||||||
|
expectedMembers: ['alice'],
|
||||||
|
launchPhase: 'active',
|
||||||
|
updatedAt: '2026-05-02T10:00:02.000Z',
|
||||||
|
statuses: { alice: status as any },
|
||||||
|
});
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
teamName,
|
||||||
|
expectedMembers: ['alice'],
|
||||||
|
memberSpawnStatuses: new Map([['alice', status]]),
|
||||||
|
});
|
||||||
|
run.isLaunch = true;
|
||||||
|
(svc as any).launchStateStore = {
|
||||||
|
read: vi.fn(async () => previousSnapshot),
|
||||||
|
write: vi.fn(async () => {}),
|
||||||
|
clear: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
(svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) };
|
||||||
|
const invalidateRuntime = vi.spyOn(svc as any, 'invalidateRuntimeSnapshotCaches');
|
||||||
|
|
||||||
|
await (svc as any).persistLaunchStateSnapshotNow(run, 'active');
|
||||||
|
|
||||||
|
expect((svc as any).launchStateStore.write).toHaveBeenCalledTimes(1);
|
||||||
|
expect(invalidateRuntime).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not skip the first write for a new run even when the previous snapshot is semantically equal', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-05-02T10:00:05.000Z'));
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const teamName = 'launch-state-new-run-team';
|
||||||
|
const status = createMemberSpawnStatusEntry({
|
||||||
|
updatedAt: '2026-05-02T10:00:00.000Z',
|
||||||
|
firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
const previousSnapshot = snapshotFromRuntimeMemberStatuses({
|
||||||
|
teamName,
|
||||||
|
expectedMembers: ['alice'],
|
||||||
|
launchPhase: 'active',
|
||||||
|
updatedAt: '2026-05-02T10:00:02.000Z',
|
||||||
|
statuses: { alice: status as any },
|
||||||
|
});
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
teamName,
|
||||||
|
expectedMembers: ['alice'],
|
||||||
|
memberSpawnStatuses: new Map([['alice', status]]),
|
||||||
|
});
|
||||||
|
run.isLaunch = true;
|
||||||
|
(svc as any).launchStateStore = {
|
||||||
|
read: vi.fn(async () => previousSnapshot),
|
||||||
|
write: vi.fn(async () => {}),
|
||||||
|
clear: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
(svc as any).launchStateWrittenRunIdByTeam.set(teamName, 'previous-run-id');
|
||||||
|
(svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) };
|
||||||
|
const invalidateRuntime = vi.spyOn(svc as any, 'invalidateRuntimeSnapshotCaches');
|
||||||
|
|
||||||
|
const result = await (svc as any).persistLaunchStateSnapshotNow(run, 'active');
|
||||||
|
|
||||||
|
expect(result.updatedAt).toBe('2026-05-02T10:00:05.000Z');
|
||||||
|
expect((svc as any).launchStateStore.write).toHaveBeenCalledTimes(1);
|
||||||
|
expect(invalidateRuntime).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes and invalidates runtime cache when launch-state semantics change', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-05-02T10:00:05.000Z'));
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const teamName = 'launch-state-change-team';
|
||||||
|
const previousStatus = createMemberSpawnStatusEntry({
|
||||||
|
status: 'waiting',
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
updatedAt: '2026-05-02T10:00:00.000Z',
|
||||||
|
firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
const nextStatus = createMemberSpawnStatusEntry({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
hardFailure: false,
|
||||||
|
livenessSource: 'heartbeat',
|
||||||
|
updatedAt: '2026-05-02T10:00:05.000Z',
|
||||||
|
firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z',
|
||||||
|
lastHeartbeatAt: '2026-05-02T10:00:05.000Z',
|
||||||
|
});
|
||||||
|
const previousSnapshot = snapshotFromRuntimeMemberStatuses({
|
||||||
|
teamName,
|
||||||
|
expectedMembers: ['alice'],
|
||||||
|
launchPhase: 'active',
|
||||||
|
updatedAt: '2026-05-02T10:00:02.000Z',
|
||||||
|
statuses: { alice: previousStatus as any },
|
||||||
|
});
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
teamName,
|
||||||
|
expectedMembers: ['alice'],
|
||||||
|
memberSpawnStatuses: new Map([['alice', nextStatus]]),
|
||||||
|
});
|
||||||
|
run.isLaunch = true;
|
||||||
|
(svc as any).launchStateStore = {
|
||||||
|
read: vi.fn(async () => previousSnapshot),
|
||||||
|
write: vi.fn(async () => {}),
|
||||||
|
clear: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
(svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) };
|
||||||
|
const invalidateRuntime = vi.spyOn(svc as any, 'invalidateRuntimeSnapshotCaches');
|
||||||
|
|
||||||
|
const result = await (svc as any).persistLaunchStateSnapshotNow(run, 'active');
|
||||||
|
|
||||||
|
expect(result.members.alice?.launchState).toBe('confirmed_alive');
|
||||||
|
expect((svc as any).launchStateStore.write).toHaveBeenCalledTimes(1);
|
||||||
|
expect(invalidateRuntime).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getTeamAgentRuntimeSnapshot', () => {
|
describe('getTeamAgentRuntimeSnapshot', () => {
|
||||||
|
it('dedupes concurrent runtime snapshot probes for the same team', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
(svc as any).configReader = {
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', agentType: 'team-lead' },
|
||||||
|
{ name: 'alice', model: 'gpt-5.4-mini' },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
(svc as any).readPersistedRuntimeMembers = vi.fn(() => [
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
agentId: 'alice@runtime-team',
|
||||||
|
tmuxPaneId: '%1',
|
||||||
|
backendType: 'tmux',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
(svc as any).aliveRunByTeam.set('runtime-team', 'run-1');
|
||||||
|
(svc as any).runs.set('run-1', {
|
||||||
|
runId: 'run-1',
|
||||||
|
child: { pid: 111 },
|
||||||
|
request: { model: 'gpt-5.4' },
|
||||||
|
processKilled: false,
|
||||||
|
cancelRequested: false,
|
||||||
|
spawnContext: null,
|
||||||
|
});
|
||||||
|
const paneInfo = createDeferred<Map<string, { paneId: string; panePid: number }>>();
|
||||||
|
vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockReturnValueOnce(
|
||||||
|
paneInfo.promise as ReturnType<typeof listTmuxPaneRuntimeInfoForCurrentPlatform>
|
||||||
|
);
|
||||||
|
vi.mocked(pidusage).mockResolvedValueOnce({
|
||||||
|
'111': createPidusageStat(111, 123_000_000),
|
||||||
|
'222': createPidusageStat(222, 456_000_000),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const first = svc.getTeamAgentRuntimeSnapshot('runtime-team');
|
||||||
|
const second = svc.getTeamAgentRuntimeSnapshot('runtime-team');
|
||||||
|
paneInfo.resolve(
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
'%1',
|
||||||
|
{
|
||||||
|
paneId: '%1',
|
||||||
|
panePid: 222,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const [firstSnapshot, secondSnapshot] = await Promise.all([first, second]);
|
||||||
|
|
||||||
|
expect(listTmuxPaneRuntimeInfoForCurrentPlatform).toHaveBeenCalledTimes(1);
|
||||||
|
expect(pidusage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(firstSnapshot.members.alice?.pid).toBe(222);
|
||||||
|
expect(secondSnapshot.members.alice?.pid).toBe(222);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cache live runtime metadata when invalidated while the probe is in flight', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
(svc as any).configReader = {
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', agentType: 'team-lead' },
|
||||||
|
{ name: 'alice', model: 'gpt-5.4-mini' },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const processRows = createDeferred<Awaited<ReturnType<typeof listRuntimeProcessesForCurrentTmuxPlatform>>>();
|
||||||
|
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform)
|
||||||
|
.mockReturnValueOnce(processRows.promise)
|
||||||
|
.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const first = (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team') as Promise<
|
||||||
|
Map<string, unknown>
|
||||||
|
>;
|
||||||
|
(svc as any).invalidateRuntimeSnapshotCaches('runtime-team');
|
||||||
|
processRows.resolve([]);
|
||||||
|
await first;
|
||||||
|
|
||||||
|
await (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team');
|
||||||
|
|
||||||
|
expect(listRuntimeProcessesForCurrentTmuxPlatform).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns cloned live runtime metadata maps from cache', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
(svc as any).configReader = {
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', agentType: 'team-lead' },
|
||||||
|
{ name: 'alice', model: 'gpt-5.4-mini' },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const first = (await (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team')) as Map<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(first.has('alice')).toBe(true);
|
||||||
|
first.delete('alice');
|
||||||
|
|
||||||
|
const second = (await (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team')) as Map<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
|
||||||
|
expect(second.has('alice')).toBe(true);
|
||||||
|
expect(listRuntimeProcessesForCurrentTmuxPlatform).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears runtime probe caches when starting a new run for the team', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
(svc as any).configReader = {
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', agentType: 'team-lead' },
|
||||||
|
{ name: 'alice', model: 'gpt-5.4-mini' },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform)
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team');
|
||||||
|
(svc as any).resetTeamScopedTransientStateForNewRun('runtime-team');
|
||||||
|
await (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team');
|
||||||
|
|
||||||
|
expect(listRuntimeProcessesForCurrentTmuxPlatform).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cache a probe that started before runtime adapter evidence was installed', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
(svc as any).configReader = {
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', agentType: 'team-lead' },
|
||||||
|
{ name: 'alice', providerId: 'opencode', model: 'gpt-5.4-mini' },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
(svc as any).provisioningRunByTeam.set('runtime-team', 'run-1');
|
||||||
|
const processRows = createDeferred<Awaited<ReturnType<typeof listRuntimeProcessesForCurrentTmuxPlatform>>>();
|
||||||
|
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform)
|
||||||
|
.mockReturnValueOnce(processRows.promise)
|
||||||
|
.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const first = (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team') as Promise<
|
||||||
|
Map<string, unknown>
|
||||||
|
>;
|
||||||
|
(svc as any).runtimeAdapterRunByTeam.set('runtime-team', {
|
||||||
|
runId: 'run-1',
|
||||||
|
providerId: 'opencode',
|
||||||
|
cwd: '/tmp/runtime-project',
|
||||||
|
members: {
|
||||||
|
alice: {
|
||||||
|
providerId: 'opencode',
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
runtimePid: 333,
|
||||||
|
livenessKind: 'runtime_process',
|
||||||
|
pidSource: 'agent_process_table',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
(svc as any).invalidateRuntimeSnapshotCaches('runtime-team');
|
||||||
|
processRows.resolve([]);
|
||||||
|
await first;
|
||||||
|
|
||||||
|
await (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team');
|
||||||
|
|
||||||
|
expect(listRuntimeProcessesForCurrentTmuxPlatform).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('uses batched pidusage rss values for lead and teammates', async () => {
|
it('uses batched pidusage rss values for lead and teammates', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
|
|
@ -3438,6 +3986,111 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses snapshot config reads for OpenCode member delivery routing', async () => {
|
||||||
|
const getConfig = vi.fn(async () => {
|
||||||
|
throw new Error('verified config read should not be used for delivery routing');
|
||||||
|
});
|
||||||
|
const getConfigSnapshot = vi.fn(async () => ({
|
||||||
|
projectPath: '/repo',
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
|
||||||
|
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
const svc = new TeamProvisioningService({
|
||||||
|
getConfig,
|
||||||
|
getConfigSnapshot,
|
||||||
|
} as any);
|
||||||
|
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
ok: true,
|
||||||
|
providerId: 'opencode',
|
||||||
|
memberName: String(input.memberName),
|
||||||
|
sessionId: 'oc-session-bob',
|
||||||
|
diagnostics: [],
|
||||||
|
}));
|
||||||
|
svc.setRuntimeAdapterRegistry(
|
||||||
|
new TeamRuntimeAdapterRegistry([
|
||||||
|
{
|
||||||
|
providerId: 'opencode',
|
||||||
|
prepare: vi.fn(),
|
||||||
|
launch: vi.fn(),
|
||||||
|
reconcile: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
sendMessageToMember,
|
||||||
|
} as any,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
(svc as any).teamMetaStore = {
|
||||||
|
getMeta: vi.fn(async () => ({
|
||||||
|
launchIdentity: { providerId: 'codex' },
|
||||||
|
providerId: 'codex',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
(svc as any).membersMetaStore = {
|
||||||
|
getMembers: vi.fn(async () => [
|
||||||
|
{ name: 'bob', providerId: 'opencode', model: 'opencode/minimax-m2.5-free' },
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
(svc as any).setSecondaryRuntimeRun({
|
||||||
|
teamName: 'team-a',
|
||||||
|
runId: 'opencode-run-bob',
|
||||||
|
providerId: 'opencode',
|
||||||
|
laneId: 'secondary:opencode:bob',
|
||||||
|
memberName: 'bob',
|
||||||
|
cwd: '/repo',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||||
|
memberName: 'bob',
|
||||||
|
text: 'hello bob',
|
||||||
|
messageId: 'msg-1',
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ delivered: true });
|
||||||
|
|
||||||
|
expect(getConfigSnapshot).toHaveBeenCalledWith('team-a');
|
||||||
|
expect(getConfig).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves OpenCode runtime lane members from one snapshot directory read', async () => {
|
||||||
|
const getConfig = vi.fn(async () => {
|
||||||
|
throw new Error('verified config read should not be used for lane member resolution');
|
||||||
|
});
|
||||||
|
const getConfigSnapshot = vi.fn(async () => ({
|
||||||
|
projectPath: '/repo',
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
|
||||||
|
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||||
|
{ name: 'alice', providerId: 'codex', model: 'gpt-5.4' },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
const svc = new TeamProvisioningService({
|
||||||
|
getConfig,
|
||||||
|
getConfigSnapshot,
|
||||||
|
} as any);
|
||||||
|
(svc as any).teamMetaStore = {
|
||||||
|
getMeta: vi.fn(async () => ({
|
||||||
|
launchIdentity: { providerId: 'codex' },
|
||||||
|
providerId: 'codex',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
(svc as any).membersMetaStore = {
|
||||||
|
getMembers: vi.fn(async () => [
|
||||||
|
{ name: 'bob', providerId: 'opencode', model: 'opencode/minimax-m2.5-free' },
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
(svc as any).resolveOpenCodeMembersForRuntimeLane(
|
||||||
|
'team-a',
|
||||||
|
'secondary:opencode:bob'
|
||||||
|
)
|
||||||
|
).resolves.toEqual(['bob']);
|
||||||
|
|
||||||
|
expect(getConfigSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getConfig).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('delivers OpenCode secondary-lane messages to the member worktree cwd after restart', async () => {
|
it('delivers OpenCode secondary-lane messages to the member worktree cwd after restart', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
|
@ -6530,6 +7183,33 @@ describe('TeamProvisioningService', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps OpenCode bootstrap check-in allowlist on verified config reads', async () => {
|
||||||
|
const getConfig = vi.fn(async () => ({
|
||||||
|
teamName: 'mixed-team',
|
||||||
|
members: [{ name: 'bob', providerId: 'opencode' }],
|
||||||
|
}));
|
||||||
|
const getConfigSnapshot = vi.fn(async () => {
|
||||||
|
throw new Error('snapshot config read should not be used for bootstrap check-in guards');
|
||||||
|
});
|
||||||
|
const svc = new TeamProvisioningService({
|
||||||
|
getConfig,
|
||||||
|
getConfigSnapshot,
|
||||||
|
} as any);
|
||||||
|
(svc as any).membersMetaStore = {
|
||||||
|
getMembers: vi.fn(async () => []),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
(svc as any).assertOpenCodeRuntimeMemberCheckinAllowed({
|
||||||
|
teamName: 'mixed-team',
|
||||||
|
memberName: 'bob',
|
||||||
|
})
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(getConfig).toHaveBeenCalledWith('mixed-team');
|
||||||
|
expect(getConfigSnapshot).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects duplicate OpenCode bootstrap check-ins for members removed after the first check-in', async () => {
|
it('rejects duplicate OpenCode bootstrap check-ins for members removed after the first check-in', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const previousSnapshot = {
|
const previousSnapshot = {
|
||||||
|
|
@ -8709,6 +9389,38 @@ describe('TeamProvisioningService', () => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it('invalidates config cache after writing OpenCode team config', async () => {
|
||||||
|
const teamName = 'opencode-config-cache-prime';
|
||||||
|
fs.mkdirSync(path.join(tempTeamsBase, teamName), { recursive: true });
|
||||||
|
const invalidateSpy = vi.spyOn(TeamConfigReader, 'invalidateTeam');
|
||||||
|
const { svc } = createSafeLaunchService();
|
||||||
|
|
||||||
|
await (svc as any).writeOpenCodeTeamConfig(
|
||||||
|
{
|
||||||
|
teamName,
|
||||||
|
displayName: 'OpenCode Config Cache Prime',
|
||||||
|
cwd: tempClaudeRoot,
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'openrouter/test/model',
|
||||||
|
effort: 'medium',
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'bob',
|
||||||
|
role: 'Developer',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'openrouter/test/model',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith(teamName);
|
||||||
|
expect((await new TeamConfigReader().getConfigSnapshot(teamName))?.name).toBe(
|
||||||
|
'OpenCode Config Cache Prime'
|
||||||
|
);
|
||||||
|
invalidateSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it('starts a pure Codex team through the app createTeam path without a real CLI process', async () => {
|
it('starts a pure Codex team through the app createTeam path without a real CLI process', async () => {
|
||||||
allowConsoleLogs();
|
allowConsoleLogs();
|
||||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||||
|
|
@ -9158,6 +9870,7 @@ describe('TeamProvisioningService', () => {
|
||||||
const teamName = 'mixed-opencode-post-launch-config';
|
const teamName = 'mixed-opencode-post-launch-config';
|
||||||
const teamDir = path.join(tempTeamsBase, teamName);
|
const teamDir = path.join(tempTeamsBase, teamName);
|
||||||
const jackWorktree = path.join(tempClaudeRoot, 'worktrees', 'jack');
|
const jackWorktree = path.join(tempClaudeRoot, 'worktrees', 'jack');
|
||||||
|
const invalidateSpy = vi.spyOn(TeamConfigReader, 'invalidateTeam');
|
||||||
fs.mkdirSync(teamDir, { recursive: true });
|
fs.mkdirSync(teamDir, { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(teamDir, 'config.json'),
|
path.join(teamDir, 'config.json'),
|
||||||
|
|
@ -9228,6 +9941,7 @@ describe('TeamProvisioningService', () => {
|
||||||
|
|
||||||
expect(config.leadSessionId).toBe('new-lead-session');
|
expect(config.leadSessionId).toBe('new-lead-session');
|
||||||
expect(config.projectPath).toBe(tempClaudeRoot);
|
expect(config.projectPath).toBe(tempClaudeRoot);
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith(teamName);
|
||||||
expect(config.members).toEqual([
|
expect(config.members).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: 'team-lead',
|
name: 'team-lead',
|
||||||
|
|
@ -9255,6 +9969,7 @@ describe('TeamProvisioningService', () => {
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
expect(config.members.some((member) => member.name === 'alice')).toBe(false);
|
expect(config.members.some((member) => member.name === 'alice')).toBe(false);
|
||||||
|
invalidateSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('launches isolated OpenCode side lanes from the resolved member worktree cwd', async () => {
|
it('launches isolated OpenCode side lanes from the resolved member worktree cwd', async () => {
|
||||||
|
|
@ -9628,11 +10343,16 @@ describe('TeamProvisioningService', () => {
|
||||||
|
|
||||||
it('expands teammate permission suggestions to the operational tool set only', async () => {
|
it('expands teammate permission suggestions to the operational tool set only', async () => {
|
||||||
allowConsoleLogs();
|
allowConsoleLogs();
|
||||||
|
const getConfig = vi.fn(async () => ({
|
||||||
|
projectPath: tempClaudeRoot,
|
||||||
|
members: [{ cwd: tempClaudeRoot }],
|
||||||
|
}));
|
||||||
|
const getConfigSnapshot = vi.fn(async () => {
|
||||||
|
throw new Error('snapshot config read should not be used for permission writes');
|
||||||
|
});
|
||||||
const svc = new TeamProvisioningService({
|
const svc = new TeamProvisioningService({
|
||||||
getConfig: vi.fn(async () => ({
|
getConfig,
|
||||||
projectPath: tempClaudeRoot,
|
getConfigSnapshot,
|
||||||
members: [{ cwd: tempClaudeRoot }],
|
|
||||||
})),
|
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
await (svc as any).respondToTeammatePermission(
|
await (svc as any).respondToTeammatePermission(
|
||||||
|
|
@ -9660,6 +10380,8 @@ describe('TeamProvisioningService', () => {
|
||||||
);
|
);
|
||||||
expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__team_stop');
|
expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__team_stop');
|
||||||
expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__kanban_clear');
|
expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__kanban_clear');
|
||||||
|
expect(getConfig).toHaveBeenCalledWith('ops-team');
|
||||||
|
expect(getConfigSnapshot).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not broaden admin/runtime teammate permission suggestions', async () => {
|
it('does not broaden admin/runtime teammate permission suggestions', async () => {
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,15 @@ const hoisted = vi.hoisted(() => {
|
||||||
return {
|
return {
|
||||||
isFile: () => true,
|
isFile: () => true,
|
||||||
size,
|
size,
|
||||||
mode: 0o644,
|
mode: 0o100644,
|
||||||
dev: 0,
|
dev: 1,
|
||||||
ino: 0,
|
ino: 1,
|
||||||
mtimeMs: 0,
|
mtimeMs: 1,
|
||||||
ctimeMs: 0,
|
ctimeMs: 1,
|
||||||
birthtimeMs: 0,
|
birthtimeMs: 1,
|
||||||
|
mtimeNs: 1n,
|
||||||
|
ctimeNs: 1n,
|
||||||
|
birthtimeNs: 1n,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -303,6 +306,45 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1);
|
expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses snapshot config reads for lead inbox relay routing', async () => {
|
||||||
|
const getConfig = vi.fn(async () => {
|
||||||
|
throw new Error('verified config read should not be used for inbox relay routing');
|
||||||
|
});
|
||||||
|
const getConfigSnapshot = vi.fn(async () => ({
|
||||||
|
name: 'My Team',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}));
|
||||||
|
const service = new TeamProvisioningService({
|
||||||
|
getConfig,
|
||||||
|
getConfigSnapshot,
|
||||||
|
} as any);
|
||||||
|
const teamName = 'my-team';
|
||||||
|
seedLeadInbox(teamName, [
|
||||||
|
{
|
||||||
|
from: 'bob',
|
||||||
|
text: 'Please assign this to Alice.',
|
||||||
|
timestamp: '2026-02-23T10:00:00.000Z',
|
||||||
|
read: false,
|
||||||
|
summary: 'Need delegation',
|
||||||
|
messageId: 'm-1',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { writeSpy } = attachAliveRun(service, teamName);
|
||||||
|
const relayPromise = service.relayLeadInboxMessages(teamName);
|
||||||
|
const run = await waitForCapture(service);
|
||||||
|
(service as any).handleStreamJsonMessage(run, {
|
||||||
|
type: 'assistant',
|
||||||
|
content: [{ type: 'text', text: 'OK, will do.' }],
|
||||||
|
});
|
||||||
|
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
|
||||||
|
|
||||||
|
await expect(relayPromise).resolves.toBe(1);
|
||||||
|
expect(writeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getConfigSnapshot).toHaveBeenCalledWith(teamName);
|
||||||
|
expect(getConfig).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('shows assistant text after relay capture has already settled', () => {
|
it('shows assistant text after relay capture has already settled', () => {
|
||||||
const service = new TeamProvisioningService();
|
const service = new TeamProvisioningService();
|
||||||
const teamName = 'my-team';
|
const teamName = 'my-team';
|
||||||
|
|
@ -2221,11 +2263,15 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
delivered: true,
|
delivered: true,
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
});
|
});
|
||||||
|
const recipientSpy = vi.spyOn(service, 'isOpenCodeRuntimeRecipient');
|
||||||
|
|
||||||
const relay = await service.relayInboxFileToLiveRecipient(teamName, 'jack');
|
const relay = await service.relayInboxFileToLiveRecipient(teamName, 'jack');
|
||||||
|
|
||||||
expect(relay).toMatchObject({ kind: 'opencode_member', relayed: 1 });
|
expect(relay).toMatchObject({ kind: 'opencode_member', relayed: 1 });
|
||||||
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
expect(recipientSpy).toHaveBeenCalledTimes(1);
|
||||||
|
const rows = JSON.parse(
|
||||||
|
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
|
||||||
|
);
|
||||||
expect(rows[0].read).toBe(true);
|
expect(rows[0].read).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -140,6 +140,30 @@ describe('TeamTranscriptProjectResolver', () => {
|
||||||
return { projectDir, jsonlPath };
|
return { projectDir, jsonlPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it('uses snapshot-capable config readers for resolver observations', async () => {
|
||||||
|
await setupClaudeRoot();
|
||||||
|
const { projectDir } = await createSessionFile('/repo/current', 'lead-session-1');
|
||||||
|
const getConfig = vi.fn(async () => {
|
||||||
|
throw new Error('verified config read should not be used for transcript observations');
|
||||||
|
});
|
||||||
|
const getConfigSnapshot = vi.fn(async () => ({
|
||||||
|
name: 'My Team',
|
||||||
|
projectPath: '/repo/current',
|
||||||
|
leadSessionId: 'lead-session-1',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}));
|
||||||
|
const resolver = new TeamTranscriptProjectResolver({
|
||||||
|
getConfig,
|
||||||
|
getConfigSnapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = await resolver.getContext('my-team');
|
||||||
|
|
||||||
|
expect(context?.projectDir).toBe(projectDir);
|
||||||
|
expect(getConfigSnapshot).toHaveBeenCalledWith('my-team');
|
||||||
|
expect(getConfig).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('repairs stale projectPath when exact leadSessionId exists only in the renamed project', async () => {
|
it('repairs stale projectPath when exact leadSessionId exists only in the renamed project', async () => {
|
||||||
await setupClaudeRoot();
|
await setupClaudeRoot();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue