From e96f97d83d1ff0a6ca488ddbdd3b57066db33357 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 2 May 2026 11:50:07 +0300 Subject: [PATCH] fix: stabilize team launch runtime status --- .../agent-graph/src/canvas/draw-agents.ts | 2 + packages/agent-graph/src/ports/types.ts | 1 + src/main/index.ts | 10 + src/main/services/team/TeamBackupService.ts | 4 + src/main/services/team/TeamConfigReader.ts | 331 +++++++++++- src/main/services/team/TeamDataService.ts | 32 +- .../services/team/TeamDataWorkerClient.ts | 22 +- .../services/team/TeamProvisioningService.ts | 257 ++++++++- .../team/TeamTranscriptProjectResolver.ts | 5 +- .../OpenCodeRuntimeManifestEvidenceReader.ts | 5 + .../opencode/store/RuntimeStoreManifest.ts | 6 + .../team/opencode/store/VersionedJsonStore.ts | 67 ++- src/main/services/team/teamDataWorkerTypes.ts | 9 +- src/main/workers/team-data-worker.ts | 6 + .../components/team/members/MemberCard.tsx | 2 + src/renderer/utils/memberHelpers.ts | 40 ++ test/main/ipc/teams.test.ts | 2 + .../services/team/TeamConfigReader.test.ts | 489 +++++++++++++++++- .../services/team/TeamDataService.test.ts | 23 + .../team/TeamDataWorkerClient.test.ts | 143 +++++ .../team/TeamProvisioningService.test.ts | 335 +++++++++++- test/renderer/utils/memberHelpers.test.ts | 74 +++ 22 files changed, 1784 insertions(+), 81 deletions(-) create mode 100644 test/main/services/team/TeamDataWorkerClient.test.ts diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index a14dbcd2..9c2715cd 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -264,6 +264,7 @@ function drawLaunchStage( ctx.save(); switch (visualState) { + case 'queued': case 'waiting': { const ringR = r + 8 + Math.sin(time * 3.2) * 1.4; const pulseAlpha = 0.28 + 0.18 * (0.5 + 0.5 * Math.sin(time * 3.2)); @@ -778,6 +779,7 @@ function truncateSubLabel(ctx: CanvasRenderingContext2D, label: string, r: numbe function getLaunchStatusColor(visualState: GraphNode['launchVisualState']): string { switch (visualState) { + case 'queued': case 'waiting': return hexWithAlpha('#d4d4d8', 0.8); case 'spawning': diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index e0f566d0..8a990dbb 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -27,6 +27,7 @@ export type GraphLaunchVisualState = | 'registered_only' | 'stale_runtime' | 'settling' + | 'queued' | 'error' | 'skipped'; diff --git a/src/main/index.ts b/src/main/index.ts index a7ab29af..8fe3d823 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -136,6 +136,7 @@ import { writeTeamControlApiState, } from './services/team/TeamControlApiState'; import { TeamInboxReader } from './services/team/TeamInboxReader'; +import { getTeamDataWorkerClient } from './services/team/TeamDataWorkerClient'; import { TeamMemberRuntimeAdvisoryService } from './services/team/TeamMemberRuntimeAdvisoryService'; import { createTeamReconcileDrainScheduler, @@ -807,6 +808,11 @@ function wireFileWatcherEvents(context: ServiceContext): void { const detail = typeof row.detail === 'string' ? row.detail : ''; memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent); + if (row.type === 'config' && detail === 'config.json') { + TeamConfigReader.invalidateTeam(teamName); + getTeamDataWorkerClient().invalidateTeamConfig(teamName); + } + if ( teamDataService && (row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config') @@ -1197,6 +1203,10 @@ async function initializeServices(): Promise { }); const forwardTeamChange = (event: TeamChangeEvent): void => { + if (event.type === 'config' && event.detail === 'config.json') { + TeamConfigReader.invalidateTeam(event.teamName); + getTeamDataWorkerClient().invalidateTeamConfig(event.teamName); + } if ( teamDataService && (event.type === 'inbox' || event.type === 'lead-message' || event.type === 'config') diff --git a/src/main/services/team/TeamBackupService.ts b/src/main/services/team/TeamBackupService.ts index 8035e045..8d57d674 100644 --- a/src/main/services/team/TeamBackupService.ts +++ b/src/main/services/team/TeamBackupService.ts @@ -11,6 +11,8 @@ import { } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; +import { TeamConfigReader } from './TeamConfigReader'; + const logger = createLogger('TeamBackupService'); // --------------------------------------------------------------------------- @@ -602,6 +604,7 @@ export class TeamBackupService { if (config._backupIdentityId === identityId) return; config._backupIdentityId = identityId; await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); + TeamConfigReader.invalidateTeam(teamName); } catch { // best-effort — config may not exist yet } @@ -661,6 +664,7 @@ export class TeamBackupService { await fs.promises.mkdir(path.dirname(configDest), { recursive: true }); const content = await fs.promises.readFile(configBackup, 'utf8'); await atomicWriteAsync(configDest, content); + TeamConfigReader.invalidateTeam(teamName); count++; } catch (err: unknown) { logger.warn(`[Backup] Failed to restore config.json for ${teamName}: ${String(err)}`); diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 3b1fbc38..b3060e73 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -37,16 +37,38 @@ const LARGE_CONFIG_BYTES = 512 * 1024; const CONFIG_HEAD_BYTES = 64 * 1024; const MAX_CONFIG_READ_BYTES = 10 * 1024 * 1024; // 10MB hard limit for full config reads const PER_TEAM_READ_TIMEOUT_MS = 5_000; -const GET_CONFIG_CACHE_TTL_MS = 750; const GET_CONFIG_SLOW_READ_WARN_MS = 500; +const CONFIG_SNAPSHOT_RECENT_STAT_FAILURE_FALLBACK_MS = 5_000; +const COARSE_FS_FULL_VERIFY_MS = 1_500; const MAX_SESSION_HISTORY_IN_SUMMARY = 2000; const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200; const MAX_LAUNCH_STATE_BYTES = 32 * 1024; const TEAM_LAUNCH_STATE_FILE = 'launch-state.json'; +export interface TeamConfigFingerprint { + size: string; + mode: string; + dev?: string; + ino?: string; + mtimeNs?: string; + ctimeNs?: string; + birthtimeNs?: string; + mtimeMs: number; + ctimeMs: number; + birthtimeMs: number; +} + +interface InternalTeamConfigFingerprint extends TeamConfigFingerprint { + isFile: boolean; + highResolution: boolean; + numericSize: number; +} + interface CachedTeamConfig { value: TeamConfig; - expiresAt: number; + fingerprint: InternalTeamConfigFingerprint | null; + verifiedAt: number; + fullVerifiedAt: number; } interface ConfigReadTiming { @@ -178,10 +200,65 @@ function cloneConfig(config: TeamConfig): TeamConfig { export class TeamConfigReader { private static readonly configCacheByPath = new Map(); private static readonly configReadInFlightByPath = new Map>(); + private static readonly configStatInFlightByPath = new Map< + string, + Promise + >(); + private static readonly configGenerationByPath = new Map(); static clearCacheForTests(): void { TeamConfigReader.configCacheByPath.clear(); TeamConfigReader.configReadInFlightByPath.clear(); + TeamConfigReader.configStatInFlightByPath.clear(); + TeamConfigReader.configGenerationByPath.clear(); + } + + static invalidateTeam(teamName: string): void { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + TeamConfigReader.invalidatePath(configPath); + } + + static invalidatePath(configPath: string): void { + TeamConfigReader.configCacheByPath.delete(configPath); + TeamConfigReader.configReadInFlightByPath.delete(configPath); + TeamConfigReader.configStatInFlightByPath.delete(configPath); + TeamConfigReader.bumpConfigGeneration(configPath); + } + + private static invalidatePathForGeneration( + configPath: string, + expectedGeneration?: number + ): void { + if ( + typeof expectedGeneration === 'number' && + TeamConfigReader.getConfigGeneration(configPath) !== expectedGeneration + ) { + return; + } + TeamConfigReader.invalidatePath(configPath); + } + + static async primeConfig( + teamName: string, + config: TeamConfig, + fingerprint?: TeamConfigFingerprint | null + ): Promise { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + const generation = TeamConfigReader.bumpConfigGeneration(configPath); + let internalFingerprint: InternalTeamConfigFingerprint | null = null; + if (fingerprint) { + internalFingerprint = { + ...fingerprint, + isFile: true, + highResolution: Boolean(fingerprint.mtimeNs || fingerprint.ctimeNs), + numericSize: Number(fingerprint.size), + }; + } else { + internalFingerprint = await TeamConfigReader.readConfigFingerprint(configPath).catch( + () => null + ); + } + TeamConfigReader.storeConfigCache(configPath, config, internalFingerprint, true, generation); } constructor( @@ -533,27 +610,18 @@ export class TeamConfigReader { } async getConfig(teamName: string): Promise { - const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); - const now = Date.now(); - const cached = TeamConfigReader.configCacheByPath.get(configPath); - if (cached && cached.expiresAt > now) { - return cloneConfig(cached.value); - } + return this.getConfigVerified(teamName); + } + async getConfigVerified(teamName: string): Promise { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); const existingRead = TeamConfigReader.configReadInFlightByPath.get(configPath); if (existingRead) { return this.resolveConfigRead(teamName, configPath, existingRead); } - const readPromise = this.readConfigFromDisk(teamName, configPath).then((config) => { - if (config) { - TeamConfigReader.configCacheByPath.set(configPath, { - value: cloneConfig(config), - expiresAt: Date.now() + GET_CONFIG_CACHE_TTL_MS, - }); - } - return config; - }); + const generation = TeamConfigReader.getConfigGeneration(configPath); + const readPromise = this.readConfigFromDisk(teamName, configPath, null, true, generation); TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise); try { @@ -565,6 +633,88 @@ export class TeamConfigReader { } } + async getConfigSnapshot(teamName: string): Promise { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + + for (let attempt = 0; attempt < 3; attempt++) { + const generationAtStart = TeamConfigReader.getConfigGeneration(configPath); + let fingerprint: InternalTeamConfigFingerprint | null; + + try { + fingerprint = await TeamConfigReader.getConfigFingerprint(configPath); + } catch (error) { + if (TeamConfigReader.getConfigGeneration(configPath) !== generationAtStart) { + continue; + } + const cached = TeamConfigReader.configCacheByPath.get(configPath); + if ( + cached && + Date.now() - cached.verifiedAt <= CONFIG_SNAPSHOT_RECENT_STAT_FAILURE_FALLBACK_MS + ) { + logger.warn( + `[getConfigSnapshot] config_snapshot_stat_failed_using_recent_cache team=${teamName} error=${ + error instanceof Error ? error.message : String(error) + }` + ); + return cloneConfig(cached.value); + } + return null; + } + + if (TeamConfigReader.getConfigGeneration(configPath) !== generationAtStart) { + continue; + } + + if (!fingerprint?.isFile || fingerprint.numericSize > MAX_CONFIG_READ_BYTES) { + TeamConfigReader.invalidatePathForGeneration(configPath, generationAtStart); + if (fingerprint && fingerprint.numericSize > MAX_CONFIG_READ_BYTES) { + logger.warn( + `Refusing to load oversized config.json (${fingerprint.numericSize} bytes) for team: ${teamName}` + ); + } + return null; + } + + const cached = TeamConfigReader.configCacheByPath.get(configPath); + if ( + cached?.fingerprint && + TeamConfigReader.fingerprintsEqual(cached.fingerprint, fingerprint) + ) { + const now = Date.now(); + const mustRevalidateCoarseFingerprint = + !fingerprint.highResolution && now - cached.fullVerifiedAt >= COARSE_FS_FULL_VERIFY_MS; + if (!mustRevalidateCoarseFingerprint) { + cached.verifiedAt = now; + return cloneConfig(cached.value); + } + } + + const existingRead = TeamConfigReader.configReadInFlightByPath.get(configPath); + if (existingRead) { + return this.resolveConfigRead(teamName, configPath, existingRead); + } + + const generation = TeamConfigReader.getConfigGeneration(configPath); + const readPromise = this.readConfigFromDisk( + teamName, + configPath, + fingerprint, + true, + generation + ); + TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise); + try { + return await this.resolveConfigRead(teamName, configPath, readPromise); + } finally { + if (TeamConfigReader.configReadInFlightByPath.get(configPath) === readPromise) { + TeamConfigReader.configReadInFlightByPath.delete(configPath); + } + } + } + + return null; + } + private async resolveConfigRead( teamName: string, configPath: string, @@ -578,9 +728,121 @@ export class TeamConfigReader { } } + private static async getConfigFingerprint( + configPath: string + ): Promise { + const existing = TeamConfigReader.configStatInFlightByPath.get(configPath); + if (existing) return existing; + + const statPromise = TeamConfigReader.readConfigFingerprint(configPath).finally(() => { + if (TeamConfigReader.configStatInFlightByPath.get(configPath) === statPromise) { + TeamConfigReader.configStatInFlightByPath.delete(configPath); + } + }); + TeamConfigReader.configStatInFlightByPath.set(configPath, statPromise); + return statPromise; + } + + private static async readConfigFingerprint( + configPath: string + ): Promise { + let stat: fs.BigIntStats; + try { + stat = await withReadTimeout( + fs.promises.stat(configPath, { bigint: true }), + PER_TEAM_READ_TIMEOUT_MS + ); + } catch (error) { + const code = typeof error === 'object' && error ? (error as { code?: unknown }).code : null; + if (code === 'ENOENT') { + return null; + } + throw error; + } + + const highResStat = stat as fs.BigIntStats & { + mtimeNs?: bigint; + ctimeNs?: bigint; + birthtimeNs?: bigint; + }; + const mtimeNs = highResStat.mtimeNs; + const ctimeNs = highResStat.ctimeNs; + const birthtimeNs = highResStat.birthtimeNs; + + return { + size: stat.size.toString(), + mode: stat.mode.toString(), + dev: stat.dev.toString(), + ino: stat.ino.toString(), + mtimeNs: typeof mtimeNs === 'bigint' ? mtimeNs.toString() : undefined, + ctimeNs: typeof ctimeNs === 'bigint' ? ctimeNs.toString() : undefined, + birthtimeNs: typeof birthtimeNs === 'bigint' ? birthtimeNs.toString() : undefined, + mtimeMs: Number(stat.mtimeMs), + ctimeMs: Number(stat.ctimeMs), + birthtimeMs: Number(stat.birthtimeMs), + isFile: stat.isFile(), + highResolution: typeof mtimeNs === 'bigint' || typeof ctimeNs === 'bigint', + numericSize: Number(stat.size), + }; + } + + private static fingerprintsEqual( + a: InternalTeamConfigFingerprint, + b: InternalTeamConfigFingerprint + ): boolean { + return ( + a.size === b.size && + a.mode === b.mode && + a.dev === b.dev && + a.ino === b.ino && + a.mtimeNs === b.mtimeNs && + a.ctimeNs === b.ctimeNs && + a.birthtimeNs === b.birthtimeNs && + a.mtimeMs === b.mtimeMs && + a.ctimeMs === b.ctimeMs && + a.birthtimeMs === b.birthtimeMs + ); + } + + private static storeConfigCache( + configPath: string, + config: TeamConfig, + fingerprint: InternalTeamConfigFingerprint | null, + fullVerified: boolean, + expectedGeneration?: number + ): void { + if ( + typeof expectedGeneration === 'number' && + TeamConfigReader.getConfigGeneration(configPath) !== expectedGeneration + ) { + return; + } + const now = Date.now(); + const previous = TeamConfigReader.configCacheByPath.get(configPath); + TeamConfigReader.configCacheByPath.set(configPath, { + value: cloneConfig(config), + fingerprint, + verifiedAt: now, + fullVerifiedAt: fullVerified ? now : (previous?.fullVerifiedAt ?? now), + }); + } + + private static getConfigGeneration(configPath: string): number { + return TeamConfigReader.configGenerationByPath.get(configPath) ?? 0; + } + + private static bumpConfigGeneration(configPath: string): number { + const next = TeamConfigReader.getConfigGeneration(configPath) + 1; + TeamConfigReader.configGenerationByPath.set(configPath, next); + return next; + } + private async readConfigFromDisk( teamName: string, - configPath: string + configPath: string, + knownFingerprint: InternalTeamConfigFingerprint | null = null, + updateCache = false, + cacheGeneration?: number ): Promise { const startedAt = performance.now(); let size: number | null = null; @@ -599,17 +861,20 @@ export class TeamConfigReader { try { const statStartedAt = performance.now(); - const stat = await fs.promises.stat(configPath); + const fingerprint = + knownFingerprint ?? (await TeamConfigReader.getConfigFingerprint(configPath)); statMs = Math.round(performance.now() - statStartedAt); - size = stat.size; + size = fingerprint?.numericSize ?? null; // Safety: refuse special files and huge/binary configs - if (!stat.isFile()) { + if (!fingerprint?.isFile) { + TeamConfigReader.invalidatePathForGeneration(configPath, cacheGeneration); return null; } - if (stat.size > MAX_CONFIG_READ_BYTES) { + if (fingerprint.numericSize > MAX_CONFIG_READ_BYTES) { + TeamConfigReader.invalidatePathForGeneration(configPath, cacheGeneration); logger.warn( - `Refusing to load oversized config.json (${stat.size} bytes) for team: ${teamName}` + `Refusing to load oversized config.json (${fingerprint.numericSize} bytes) for team: ${teamName}` ); return null; } @@ -622,6 +887,7 @@ export class TeamConfigReader { const config = JSON.parse(raw) as TeamConfig; parseMs = Math.round(performance.now() - parseStartedAt); if (typeof config.name !== 'string' || config.name.trim() === '') { + TeamConfigReader.invalidatePathForGeneration(configPath, cacheGeneration); return null; } const resolvedProjectPath = resolveProjectPathFromConfig(config); @@ -633,10 +899,24 @@ export class TeamConfigReader { if (totalMs >= GET_CONFIG_SLOW_READ_WARN_MS) { logger.warn(`[getConfig] slow read diag=${JSON.stringify(buildTiming())}`); } + if (updateCache) { + TeamConfigReader.storeConfigCache( + configPath, + resolvedConfig, + fingerprint, + true, + cacheGeneration + ); + } return resolvedConfig; } catch (error) { + TeamConfigReader.invalidatePathForGeneration(configPath, cacheGeneration); if (error instanceof FileReadTimeoutError) { logger.warn(`[getConfig] ${error.message} diag=${JSON.stringify(buildTiming())}`); + } else if (error instanceof Error && error.message === 'Team config read timeout') { + logger.warn( + `[getConfig] Timed out after ${PER_TEAM_READ_TIMEOUT_MS}ms reading ${configPath} diag=${JSON.stringify(buildTiming())}` + ); } throw error; } @@ -664,10 +944,7 @@ export class TeamConfigReader { } const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8'); - TeamConfigReader.configCacheByPath.set(configPath, { - value: cloneConfig(config), - expiresAt: Date.now() + GET_CONFIG_CACHE_TTL_MS, - }); + await TeamConfigReader.primeConfig(teamName, config); return config; } } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 054550df..b4b09fa5 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -267,6 +267,25 @@ function extractPassiveUserPeerSummaryBody(text: string): string | null { return body.length > 0 ? body : null; } +function readConfigForUiSnapshot( + configReader: TeamConfigReader & { + getConfigSnapshot?: (teamName: string) => Promise; + }, + teamName: string +): Promise { + return typeof configReader.getConfigSnapshot === 'function' + ? configReader.getConfigSnapshot(teamName) + : configReader.getConfig(teamName); +} + +function createUiSnapshotProjectResolver( + configReader: TeamConfigReader +): TeamTranscriptProjectResolver { + return new TeamTranscriptProjectResolver({ + getConfig: (teamName) => readConfigForUiSnapshot(configReader, teamName), + }); +} + function isExplicitLeadRole(role: string | undefined): boolean { const normalized = role?.trim().toLowerCase(); return normalized === 'lead' || normalized === 'team lead' || normalized === 'team-lead'; @@ -385,13 +404,13 @@ export class TeamDataService { private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(), private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(), private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache(), - private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver( + private readonly projectResolver: TeamTranscriptProjectResolver = createUiSnapshotProjectResolver( configReader ), private readonly launchStateStore: TeamLaunchStateStore = new TeamLaunchStateStore() ) { this.messageFeedService = new TeamMessageFeedService({ - getConfig: (teamName) => this.configReader.getConfig(teamName), + getConfig: (teamName) => this.readSnapshotConfig(teamName), getInboxMessages: (teamName) => this.inboxReader.getMessages(teamName), getLeadSessionMessages: (teamName, config) => this.extractLeadSessionTexts(teamName, config), getSentMessages: (teamName) => this.sentMessagesStore.readMessages(teamName), @@ -399,6 +418,10 @@ export class TeamDataService { this.memberActivityMetaService = new MemberActivityMetaService(this.messageFeedService); } + private readSnapshotConfig(teamName: string): Promise { + return readConfigForUiSnapshot(this.configReader, teamName); + } + private getController(teamName: string): AgentTeamsController { return this.controllerFactory(teamName); } @@ -1077,6 +1100,7 @@ export class TeamDataService { config.deletedAt = new Date().toISOString(); const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); + await TeamConfigReader.primeConfig(teamName, config); } async restoreTeam(teamName: string): Promise { @@ -1087,11 +1111,13 @@ export class TeamDataService { delete config.deletedAt; const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); + await TeamConfigReader.primeConfig(teamName, config); } async permanentlyDeleteTeam(teamName: string): Promise { const teamsDir = path.join(getTeamsBasePath(), teamName); await fs.promises.rm(teamsDir, { recursive: true, force: true }); + TeamConfigReader.invalidateTeam(teamName); const tasksDir = path.join(getTasksBasePath(), teamName); await fs.promises.rm(tasksDir, { recursive: true, force: true }); @@ -1113,7 +1139,7 @@ export class TeamDataService { return typeof fromTs === 'number' && typeof toTs === 'number' ? toTs - fromTs : -1; }; - const config = await this.configReader.getConfig(teamName); + const config = await this.readSnapshotConfig(teamName); if (!config) { throw new Error(`Team not found: ${teamName}`); } diff --git a/src/main/services/team/TeamDataWorkerClient.ts b/src/main/services/team/TeamDataWorkerClient.ts index 9b69cc90..b628bd6c 100644 --- a/src/main/services/team/TeamDataWorkerClient.ts +++ b/src/main/services/team/TeamDataWorkerClient.ts @@ -68,6 +68,7 @@ export class TeamDataWorkerClient { private readonly workerPath: string | null = resolveWorkerPath(); private warnedUnavailable = false; private pending = new Map(); + private getTeamDataInFlight = new Map>(); private failWorker(worker: Worker, error: Error): void { if (this.worker !== worker) return; @@ -157,7 +158,25 @@ export class TeamDataWorkerClient { async getTeamData(teamName: string): Promise { if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName'); - return this.call('getTeamData', { teamName }) as Promise; + const existing = this.getTeamDataInFlight.get(teamName); + if (existing) return existing; + + const promise = (this.call('getTeamData', { teamName }) as Promise).finally( + () => { + if (this.getTeamDataInFlight.get(teamName) === promise) { + this.getTeamDataInFlight.delete(teamName); + } + } + ); + this.getTeamDataInFlight.set(teamName, promise); + return promise; + } + + invalidateTeamConfig(teamName: string): void { + if (!SAFE_NAME_RE.test(teamName)) return; + this.getTeamDataInFlight.delete(teamName); + if (!this.worker) return; + void this.call('invalidateTeamConfig', { teamName }).catch(() => undefined); } async getMessagesPage( @@ -193,6 +212,7 @@ export class TeamDataWorkerClient { dispose(): void { this.worker?.terminate().catch(() => undefined); this.worker = null; + this.getTeamDataInFlight.clear(); for (const [, entry] of this.pending) { entry.reject(new Error('Client disposed')); } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 89429046..1188fa09 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1641,6 +1641,11 @@ interface PromptSizeSummary { const MEMBER_LAUNCH_GRACE_MS = 120_000; const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000; +const OPENCODE_BOOTSTRAP_EVIDENCE_LOCK_OPTIONS = { + acquireTimeoutMs: 45_000, + staleTimeoutMs: 60_000, + retryIntervalMs: 50, +} as const; export function shouldWarnOnUnreadableMemberAuditConfig(params: { nowMs: number; @@ -1957,10 +1962,16 @@ function hasStaleOpenCodeDiagnostics(values: readonly unknown[] | undefined): bo text.includes('registered runtime metadata without live process') || text.includes('member has persisted runtime metadata only') || text.includes('opencode bridge reported member launch failure') || + text.includes('file lock timeout') || text.includes(OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC.toLowerCase()) ); } +function isFileLockTimeoutError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.toLowerCase().includes('file lock timeout'); +} + function hasRealOpenCodeFailureDiagnostic(text: string): boolean { return ( /\bauth(?:entication|orization)?\b/.test(text) || @@ -8487,13 +8498,32 @@ export class TeamProvisioningService { previousMember: idempotent.previousMember, }); if (idempotent.state === 'duplicate') { - await this.commitOpenCodeRuntimeBootstrapSessionEvidence({ + const committed = await this.hasCommittedOpenCodeRuntimeBootstrapSessionEvidence({ teamName, runId, laneId, memberName, runtimeSessionId, + }); + if (!committed) { + await this.commitOpenCodeRuntimeBootstrapSessionEvidence({ + teamName, + runId, + laneId, + memberName, + runtimeSessionId, + observedAt, + }); + } + await this.updateOpenCodeRuntimeMemberLiveness({ + teamName, + runId, + memberName, + runtimeSessionId, observedAt, + diagnostics: payload.diagnostics, + metadata: parseRuntimeToolMetadata(payload.metadata), + reason: 'OpenCode runtime bootstrap check-in accepted', }); return { ok: true, @@ -8585,25 +8615,63 @@ export class TeamProvisioningService { const manifestStore = createRuntimeStoreManifestStore({ filePath: manifestPath, teamName: input.teamName, + lockOptions: OPENCODE_BOOTSTRAP_EVIDENCE_LOCK_OPTIONS, }); const receiptStore = createRuntimeStoreReceiptStore({ filePath: path.join(runtimeDirectory, 'opencode-runtime-receipts.json'), + lockOptions: OPENCODE_BOOTSTRAP_EVIDENCE_LOCK_OPTIONS, }); const writer = new RuntimeStoreBatchWriter(runtimeDirectory, manifestStore, receiptStore); - await writer.writeBatch({ + try { + await writer.writeBatch({ + teamName: input.teamName, + runId: input.runId, + capabilitySnapshotId: null, + behaviorFingerprint: null, + reason: 'launch_checkpoint', + writes: [ + { + descriptor, + data: { sessions }, + }, + ], + }); + } catch (error) { + if ( + isFileLockTimeoutError(error) && + (await this.hasCommittedOpenCodeRuntimeBootstrapSessionEvidence(input)) + ) { + return; + } + throw error; + } + } + + private async hasCommittedOpenCodeRuntimeBootstrapSessionEvidence(input: { + teamName: string; + runId: string; + laneId: string; + memberName: string; + runtimeSessionId: string; + }): Promise { + const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({ + teamsBasePath: getTeamsBasePath(), teamName: input.teamName, - runId: input.runId, - capabilitySnapshotId: null, - behaviorFingerprint: null, - reason: 'launch_checkpoint', - writes: [ - { - descriptor, - data: { sessions }, - }, - ], - }); + laneId: input.laneId, + }).catch(() => null); + if (!evidence?.committed) { + return false; + } + if (evidence.activeRunId && evidence.activeRunId.trim() !== input.runId) { + return false; + } + return evidence.sessions.some( + (session) => + session.id === input.runtimeSessionId && + session.runId === input.runId && + namesMatchCaseInsensitive(session.memberName, input.memberName) + ); } private async readOpenCodeRuntimeSessionStore( @@ -8918,6 +8986,25 @@ export class TeamProvisioningService { metadata?: RuntimeToolMetadata; reason: string; }): Promise { + const trackedUpdate = this.applyOpenCodeRuntimeBootstrapCheckinToTrackedRun(input); + if (trackedUpdate) { + await this.persistLaunchStateSnapshot( + trackedUpdate.run, + this.getMixedSecondaryLaunchPhase(trackedUpdate.run) + ); + this.agentRuntimeSnapshotCache.delete(input.teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(input.teamName); + if (trackedUpdate.changed) { + this.teamChangeEmitter?.({ + type: 'member-spawn', + teamName: input.teamName, + runId: input.runId, + detail: input.memberName, + }); + } + return; + } + const previous = await this.launchStateStore.read(input.teamName); const expectedMembers = previous ? this.getPersistedLaunchMemberNames(previous) @@ -9005,6 +9092,137 @@ export class TeamProvisioningService { } } + private applyOpenCodeRuntimeBootstrapCheckinToTrackedRun(input: { + teamName: string; + runId: string; + memberName: string; + runtimeSessionId: string; + observedAt: string; + diagnostics: unknown; + metadata?: RuntimeToolMetadata; + reason: string; + }): { run: ProvisioningRun; changed: boolean } | null { + const trackedRunId = this.getTrackedRunId(input.teamName); + const run = trackedRunId ? this.runs.get(trackedRunId) : undefined; + if (!run || run.processKilled || run.cancelRequested) { + return null; + } + + const lane = (run.mixedSecondaryLanes ?? []).find((candidate) => { + if (candidate.providerId !== 'opencode') { + return false; + } + if (!matchesTeamMemberIdentity(candidate.member.name, input.memberName)) { + return false; + } + return !candidate.runId || candidate.runId === input.runId; + }); + if (!lane) { + return null; + } + + const runtimePid = input.metadata?.runtimePid; + const runtimeDiagnostics = mergeRuntimeDiagnostics( + lane.result?.members[input.memberName]?.diagnostics ?? lane.diagnostics, + [ + ...normalizeRuntimeStringArray(input.diagnostics), + ...buildRuntimeToolMetadataDiagnostics(input.metadata), + 'opencode_bootstrap_evidence_committed', + ], + input.reason + ); + const evidence: TeamRuntimeMemberLaunchEvidence = { + memberName: input.memberName, + providerId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + sessionId: input.runtimeSessionId, + backendType: 'process', + ...(runtimePid ? { runtimePid, pidSource: 'runtime_bootstrap' as const } : {}), + livenessKind: 'confirmed_bootstrap', + runtimeDiagnostic: input.reason, + runtimeDiagnosticSeverity: 'info', + diagnostics: runtimeDiagnostics ?? [input.reason], + }; + + const previousLaneState = lane.state; + const previousLaneRunId = lane.runId; + const previousLaneMember = lane.result?.members[input.memberName]; + lane.runId = input.runId; + lane.state = 'finished'; + lane.diagnostics = runtimeDiagnostics ?? lane.diagnostics; + lane.result = { + ...(lane.result ?? { + runId: input.runId, + teamName: input.teamName, + launchPhase: 'finished' as const, + teamLaunchState: 'partial_pending' as const, + members: {}, + warnings: lane.warnings, + diagnostics: [], + }), + runId: input.runId, + teamName: input.teamName, + launchPhase: 'finished', + members: { + ...(lane.result?.members ?? {}), + [input.memberName]: evidence, + }, + warnings: lane.result?.warnings ?? lane.warnings, + diagnostics: runtimeDiagnostics ?? lane.result?.diagnostics ?? lane.diagnostics, + }; + lane.result.teamLaunchState = summarizeRuntimeLaunchResultMembers(lane.result.members); + + const previousStatus = + run.memberSpawnStatuses.get(input.memberName) ?? createInitialMemberSpawnStatusEntry(); + const nextStatus: MemberSpawnStatusEntry = { + ...previousStatus, + status: 'online', + launchState: 'confirmed_alive', + error: undefined, + hardFailureReason: undefined, + skippedForLaunch: undefined, + skipReason: undefined, + skippedAt: undefined, + livenessSource: 'heartbeat', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + pendingPermissionRequestIds: undefined, + firstSpawnAcceptedAt: previousStatus.firstSpawnAcceptedAt ?? input.observedAt, + lastHeartbeatAt: input.observedAt, + runtimeModel: lane.member.model, + livenessKind: 'confirmed_bootstrap', + runtimeDiagnostic: input.reason, + runtimeDiagnosticSeverity: 'info', + livenessLastCheckedAt: input.observedAt, + updatedAt: input.observedAt, + }; + run.memberSpawnStatuses.set(input.memberName, nextStatus); + run.pendingMemberRestarts?.delete(input.memberName); + this.syncMemberLaunchGraceCheck(run, input.memberName, nextStatus); + + const statusChanged = + previousStatus.status !== nextStatus.status || + previousStatus.launchState !== nextStatus.launchState || + previousStatus.bootstrapConfirmed !== nextStatus.bootstrapConfirmed || + previousStatus.runtimeAlive !== nextStatus.runtimeAlive || + previousStatus.hardFailure !== nextStatus.hardFailure || + previousStatus.livenessKind !== nextStatus.livenessKind; + const laneChanged = + previousLaneState !== lane.state || + previousLaneRunId !== lane.runId || + previousLaneMember?.sessionId !== evidence.sessionId || + previousLaneMember?.launchState !== evidence.launchState || + previousLaneMember?.bootstrapConfirmed !== evidence.bootstrapConfirmed; + + return { run, changed: statusChanged || laneChanged }; + } + private shouldEmitOpenCodeRuntimeLivenessMemberSpawnChange(input: { previousMember?: PersistedTeamLaunchMemberState; runtimeRunId: string; @@ -17663,6 +17881,7 @@ export class TeamProvisioningService { }).catch((error: unknown) => ({ state: 'invalid_store' as const, committed: false, + activeRunId: null, sessions: [], diagnostics: [ `OpenCode committed bootstrap evidence read failed: ${getErrorMessage(error)}`, @@ -17675,6 +17894,7 @@ export class TeamProvisioningService { previous, laneEntry, metaMembers, + activeRunId: evidence.activeRunId, sessions: evidence.committed ? evidence.sessions : [], diagnostics: evidence.diagnostics, }); @@ -17785,6 +18005,7 @@ export class TeamProvisioningService { previous: PersistedTeamLaunchMemberState | null; laneEntry: OpenCodeRuntimeLaneIndexEntry | null; metaMembers: Awaited>; + activeRunId: string | null; sessions: OpenCodeCommittedBootstrapSessionRecord[]; diagnostics: readonly string[]; }): Promise< @@ -17838,6 +18059,16 @@ export class TeamProvisioningService { ], }; } + if ( + params.activeRunId && + selected.runId && + params.activeRunId.trim() !== selected.runId.trim() + ) { + return { + kind: 'conflict', + diagnostics: ['opencode_overlay_session_run_mismatch'], + }; + } if (selected.runId) { const tombstoneStore = createRuntimeRunTombstoneStore({ diff --git a/src/main/services/team/TeamTranscriptProjectResolver.ts b/src/main/services/team/TeamTranscriptProjectResolver.ts index 63c1de58..cee44f65 100644 --- a/src/main/services/team/TeamTranscriptProjectResolver.ts +++ b/src/main/services/team/TeamTranscriptProjectResolver.ts @@ -186,7 +186,9 @@ export class TeamTranscriptProjectResolver { { value: TeamTranscriptProjectContext; expiresAt: number } >(); - constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {} + constructor( + private readonly configReader: Pick = new TeamConfigReader() + ) {} async getContext( teamName: string, @@ -605,6 +607,7 @@ export class TeamTranscriptProjectResolver { normalizedNextPath ); await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2)); + TeamConfigReader.invalidateTeam(teamName); logger.info( `[${teamName}] Repaired transcript projectPath via exact session match: ${normalizedNextPath}` ); diff --git a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts index 4e0599e3..cbe3ebe7 100644 --- a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts +++ b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts @@ -71,6 +71,7 @@ export interface OpenCodeCommittedBootstrapSessionRecord { export interface OpenCodeCommittedBootstrapSessionEvidence { state: RuntimeStoreManifestEntryState | 'invalid_store' | 'descriptor_missing'; committed: boolean; + activeRunId: string | null; sessions: OpenCodeCommittedBootstrapSessionRecord[]; diagnostics: string[]; } @@ -497,6 +498,7 @@ export async function readCommittedOpenCodeBootstrapSessionEvidence(params: { return { state: 'descriptor_missing', committed: false, + activeRunId: null, sessions: [], diagnostics: ['OpenCode session store descriptor is not registered.'], }; @@ -521,6 +523,7 @@ export async function readCommittedOpenCodeBootstrapSessionEvidence(params: { return { state: 'invalid_store', committed: false, + activeRunId: null, sessions: [], diagnostics: ['OpenCode runtime manifest could not be read.'], }; @@ -539,6 +542,7 @@ export async function readCommittedOpenCodeBootstrapSessionEvidence(params: { return { state: inspection.state, committed: false, + activeRunId: manifest.activeRunId, sessions: [], diagnostics, }; @@ -561,6 +565,7 @@ export async function readCommittedOpenCodeBootstrapSessionEvidence(params: { return { state: 'healthy', committed: true, + activeRunId: manifest.activeRunId, sessions, diagnostics, }; diff --git a/src/main/services/team/opencode/store/RuntimeStoreManifest.ts b/src/main/services/team/opencode/store/RuntimeStoreManifest.ts index 99e6174e..be3b2e24 100644 --- a/src/main/services/team/opencode/store/RuntimeStoreManifest.ts +++ b/src/main/services/team/opencode/store/RuntimeStoreManifest.ts @@ -3,6 +3,8 @@ import { createHash, randomUUID } from 'crypto'; import { promises as fs } from 'fs'; import * as path from 'path'; +import type { FileLockOptions } from '../../fileLock'; + import { VersionedJsonStore, VersionedJsonStoreError } from './VersionedJsonStore'; export const OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION = 1; @@ -917,6 +919,7 @@ export function createRuntimeStoreManifestStore(options: { filePath: string; teamName: string; clock?: () => Date; + lockOptions?: FileLockOptions; }): RuntimeStoreManifestStore { const clock = options.clock ?? (() => new Date()); return new RuntimeStoreManifestStore( @@ -926,6 +929,7 @@ export function createRuntimeStoreManifestStore(options: { defaultData: () => createDefaultRuntimeStoreManifest(options.teamName, clock().toISOString()), validate: validateRuntimeStoreManifest, clock, + lockOptions: options.lockOptions, }), clock ); @@ -934,6 +938,7 @@ export function createRuntimeStoreManifestStore(options: { export function createRuntimeStoreReceiptStore(options: { filePath: string; clock?: () => Date; + lockOptions?: FileLockOptions; }): RuntimeStoreReceiptStore { const clock = options.clock ?? (() => new Date()); return new RuntimeStoreReceiptStore( @@ -943,6 +948,7 @@ export function createRuntimeStoreReceiptStore(options: { defaultData: () => [], validate: validateRuntimeStoreWriteBatches, clock, + lockOptions: options.lockOptions, }), clock ); diff --git a/src/main/services/team/opencode/store/VersionedJsonStore.ts b/src/main/services/team/opencode/store/VersionedJsonStore.ts index 9da4a5e1..afbf1912 100644 --- a/src/main/services/team/opencode/store/VersionedJsonStore.ts +++ b/src/main/services/team/opencode/store/VersionedJsonStore.ts @@ -2,7 +2,7 @@ import { atomicWriteAsync } from '@main/utils/atomicWrite'; import { promises as fs } from 'fs'; import * as path from 'path'; -import { withFileLock } from '../../fileLock'; +import { type FileLockOptions, withFileLock } from '../../fileLock'; export interface VersionedJsonStoreEnvelope { schemaVersion: number; @@ -45,6 +45,7 @@ export interface VersionedJsonStoreOptions { validate: (value: unknown) => TData; clock?: () => Date; quarantineDir?: string; + lockOptions?: FileLockOptions; } export class VersionedJsonStoreError extends Error { @@ -65,6 +66,7 @@ export class VersionedJsonStore { private readonly validate: (value: unknown) => TData; private readonly clock: () => Date; private readonly quarantineDir: string | null; + private readonly lockOptions: FileLockOptions | undefined; constructor(options: VersionedJsonStoreOptions) { this.filePath = options.filePath; @@ -73,6 +75,7 @@ export class VersionedJsonStore { this.validate = options.validate; this.clock = options.clock ?? (() => new Date()); this.quarantineDir = options.quarantineDir ?? null; + this.lockOptions = options.lockOptions; } async read(): Promise> { @@ -82,36 +85,44 @@ export class VersionedJsonStore { async updateLocked( updater: (current: TData) => TData | Promise ): Promise> { - return withFileLock(this.filePath, async () => { - const current = await this.readUnlocked(); - if (!current.ok) { - throw new VersionedJsonStoreError(current.message, current.reason, current.quarantinePath); - } + return withFileLock( + this.filePath, + async () => { + const current = await this.readUnlocked(); + if (!current.ok) { + throw new VersionedJsonStoreError( + current.message, + current.reason, + current.quarantinePath + ); + } - const nextData = await updater(cloneJson(current.data)); - const validatedNextData = this.validate(nextData); - const currentJson = stableJsonStringify(current.data); - const nextJson = stableJsonStringify(validatedNextData); - const changed = current.status === 'missing' || currentJson !== nextJson; - const envelope: VersionedJsonStoreEnvelope = { - schemaVersion: this.schemaVersion, - updatedAt: changed - ? this.clock().toISOString() - : (current.envelope?.updatedAt ?? this.clock().toISOString()), - data: changed ? validatedNextData : current.data, - }; + const nextData = await updater(cloneJson(current.data)); + const validatedNextData = this.validate(nextData); + const currentJson = stableJsonStringify(current.data); + const nextJson = stableJsonStringify(validatedNextData); + const changed = current.status === 'missing' || currentJson !== nextJson; + const envelope: VersionedJsonStoreEnvelope = { + schemaVersion: this.schemaVersion, + updatedAt: changed + ? this.clock().toISOString() + : (current.envelope?.updatedAt ?? this.clock().toISOString()), + data: changed ? validatedNextData : current.data, + }; - if (changed) { - await fs.mkdir(path.dirname(this.filePath), { recursive: true }); - await atomicWriteAsync(this.filePath, `${JSON.stringify(envelope, null, 2)}\n`); - } + if (changed) { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + await atomicWriteAsync(this.filePath, `${JSON.stringify(envelope, null, 2)}\n`); + } - return { - changed, - data: envelope.data, - envelope, - }; - }); + return { + changed, + data: envelope.data, + envelope, + }; + }, + this.lockOptions + ); } private async readUnlocked(): Promise> { diff --git a/src/main/services/team/teamDataWorkerTypes.ts b/src/main/services/team/teamDataWorkerTypes.ts index ca798cc3..c130c0b8 100644 --- a/src/main/services/team/teamDataWorkerTypes.ts +++ b/src/main/services/team/teamDataWorkerTypes.ts @@ -38,18 +38,23 @@ export interface FindLogsForTaskPayload { }; } +export interface InvalidateTeamConfigPayload { + teamName: string; +} + // ── Request / Response ── export type TeamDataWorkerRequest = | { id: string; op: 'getTeamData'; payload: GetTeamDataPayload } | { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload } | { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload } - | { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload }; + | { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload } + | { id: string; op: 'invalidateTeamConfig'; payload: InvalidateTeamConfigPayload }; export type TeamDataWorkerResponse = | { id: string; ok: true; - result: TeamViewSnapshot | MessagesPage | TeamMemberActivityMeta | MemberLogSummary[]; + result: TeamViewSnapshot | MessagesPage | TeamMemberActivityMeta | MemberLogSummary[] | null; } | { id: string; ok: false; error: string }; diff --git a/src/main/workers/team-data-worker.ts b/src/main/workers/team-data-worker.ts index 871eba11..55db4473 100644 --- a/src/main/workers/team-data-worker.ts +++ b/src/main/workers/team-data-worker.ts @@ -8,6 +8,7 @@ import { parentPort } from 'node:worker_threads'; +import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import { TeamDataService } from '@main/services/team/TeamDataService'; import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder'; import { createLogger } from '@shared/utils/logger'; @@ -55,6 +56,11 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => { respond({ id: msg.id, ok: true, result }); break; } + case 'invalidateTeamConfig': { + TeamConfigReader.invalidateTeam(msg.payload.teamName); + respond({ id: msg.id, ok: true, result: null }); + break; + } case 'findLogsForTask': { const { teamName, taskId, options } = msg.payload; const intervalsKey = options?.intervals diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 4d26595b..6c9453e3 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -156,6 +156,7 @@ export const MemberCard = ({ const launchVisualState = launchPresentation.launchVisualState; const launchStatusLabel = launchPresentation.launchStatusLabel; const displayPresenceLabel = + launchVisualState === 'queued' || launchVisualState === 'runtime_pending' || launchVisualState === 'permission_pending' || launchVisualState === 'shell_only' || @@ -200,6 +201,7 @@ export const MemberCard = ({ !runtimeAdvisoryLabel && (presenceLabel === 'starting' || presenceLabel === 'connecting' || + launchVisualState === 'queued' || launchVisualState === 'runtime_pending' || launchVisualState === 'shell_only' || launchVisualState === 'runtime_candidate' || diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index d95e0756..4ce026e0 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -546,6 +546,7 @@ export function getLaunchAwarePresenceLabel( } export type MemberLaunchVisualState = + | 'queued' | 'waiting' | 'spawning' | 'permission_pending' @@ -573,6 +574,8 @@ export interface MemberLaunchPresentation { export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState): string | null { switch (visualState) { + case 'queued': + return 'queued'; case 'waiting': return 'waiting to start'; case 'spawning': @@ -602,6 +605,8 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState) function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): string | null { switch (visualState) { + case 'queued': + return SPAWN_DOT_COLORS.waiting; case 'permission_pending': case 'runtime_pending': case 'runtime_candidate': @@ -617,6 +622,29 @@ function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): str } } +function isQueuedOpenCodeLaunch( + member: ResolvedTeamMember, + spawnStatus: MemberSpawnStatus | undefined, + spawnLaunchState: MemberLaunchState | undefined, + runtimeEntry: TeamAgentRuntimeEntry | undefined, + isLaunchSettling: boolean, + isTeamProvisioning: boolean | undefined +): boolean { + if (member.providerId !== 'opencode') { + return false; + } + if (isTeamProvisioning !== true && !isLaunchSettling) { + return false; + } + if (spawnStatus !== 'waiting' || spawnLaunchState !== 'starting') { + return false; + } + + // Only label lanes as queued before runtime evidence appears. Once the + // backend has any liveness signal, show the exact runtime state instead. + return runtimeEntry == null || runtimeEntry.livenessKind == null; +} + function hasElapsedSinceIso( value: string | undefined, thresholdMs: number, @@ -783,6 +811,17 @@ export function buildMemberLaunchPresentation({ runtimeEntry?.livenessKind === 'not_found' ) { launchVisualState = 'stale_runtime'; + } else if ( + isQueuedOpenCodeLaunch( + member, + spawnStatus, + spawnLaunchState, + runtimeEntry, + isLaunchSettling, + isTeamProvisioning + ) + ) { + launchVisualState = 'queued'; } else if ( isLaunchStillStarting( spawnStatus, @@ -810,6 +849,7 @@ export function buildMemberLaunchPresentation({ const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState); const launchVisualStateDotClass = getLaunchVisualStateDotClass(launchVisualState); const shouldShowLaunchStatusAsPresence = + launchVisualState === 'queued' || launchVisualState === 'permission_pending' || launchVisualState === 'runtime_pending' || launchVisualState === 'shell_only' || diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 3070bdf9..565e1175 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -49,6 +49,7 @@ const { mockTeamDataWorkerClient } = vi.hoisted(() => ({ getMessagesPage: vi.fn(), getMemberActivityMeta: vi.fn(), findLogsForTask: vi.fn(), + invalidateTeamConfig: vi.fn(), }, })); vi.mock('@main/services/infrastructure/NotificationManager', () => ({ @@ -313,6 +314,7 @@ describe('ipc teams handlers', () => { mockTeamDataWorkerClient.getMessagesPage.mockReset(); mockTeamDataWorkerClient.getMemberActivityMeta.mockReset(); mockTeamDataWorkerClient.findLogsForTask.mockReset(); + mockTeamDataWorkerClient.invalidateTeamConfig.mockReset(); initializeTeamHandlers( service as never, provisioningService as never, diff --git a/test/main/services/team/TeamConfigReader.test.ts b/test/main/services/team/TeamConfigReader.test.ts index 88b3c47d..6171fc23 100644 --- a/test/main/services/team/TeamConfigReader.test.ts +++ b/test/main/services/team/TeamConfigReader.test.ts @@ -22,6 +22,20 @@ vi.mock('../../../../src/main/services/team/TeamFsWorkerClient', () => ({ import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader'; import { createPersistedLaunchSummaryProjection } from '../../../../src/main/services/team/TeamLaunchSummaryProjection'; +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + describe('TeamConfigReader', () => { let tempDir = ''; @@ -31,6 +45,9 @@ describe('TeamConfigReader', () => { }); afterEach(async () => { + vi.useRealTimers(); + vi.restoreAllMocks(); + TeamConfigReader.clearCacheForTests(); if (tempDir) { await fs.rm(tempDir, { recursive: true, force: true }); } @@ -256,7 +273,7 @@ describe('TeamConfigReader', () => { }); }); - it('shares in-flight getConfig reads and returns cloned cached configs', async () => { + it('shares in-flight verified reads without reusing completed cache', async () => { const teamName = 'cached-config-team'; const teamDir = path.join(tempDir, teamName); await fs.mkdir(teamDir, { recursive: true }); @@ -273,17 +290,481 @@ describe('TeamConfigReader', () => { const reader = new TeamConfigReader(); const [first, second] = await Promise.all([ - reader.getConfig(teamName), - reader.getConfig(teamName), + reader.getConfigVerified(teamName), + reader.getConfigVerified(teamName), ]); if (!first) { throw new Error('Expected config to load.'); } first.name = 'Mutated In Caller'; - const third = await reader.getConfig(teamName); + const third = await reader.getConfigVerified(teamName); expect(second?.name).toBe('Cached Config Team'); expect(third?.name).toBe('Cached Config Team'); + expect(readFileSpy).toHaveBeenCalledTimes(2); + }); + + it('uses fingerprint-validated snapshot cache without rereading unchanged config content', async () => { + const teamName = 'snapshot-cache-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'Snapshot Cache Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile'); + const statSpy = vi.spyOn(nodeFs.promises, 'stat'); + + const reader = new TeamConfigReader(); + const first = await reader.getConfigSnapshot(teamName); + if (!first) { + throw new Error('Expected config to load.'); + } + first.name = 'Mutated In Caller'; + const second = await reader.getConfigSnapshot(teamName); + + expect(second?.name).toBe('Snapshot Cache Team'); + expect(statSpy).toHaveBeenCalledTimes(2); expect(readFileSpy).toHaveBeenCalledTimes(1); }); + + it('shares in-flight snapshot stat and read work for concurrent calls', async () => { + const teamName = 'snapshot-inflight-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'Snapshot Inflight Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile'); + const statSpy = vi.spyOn(nodeFs.promises, 'stat'); + + const reader = new TeamConfigReader(); + const [first, second] = await Promise.all([ + reader.getConfigSnapshot(teamName), + reader.getConfigSnapshot(teamName), + ]); + + expect(first?.name).toBe('Snapshot Inflight Team'); + expect(second?.name).toBe('Snapshot Inflight Team'); + expect(statSpy).toHaveBeenCalledTimes(1); + expect(readFileSpy).toHaveBeenCalledTimes(1); + }); + + it('rereads snapshot when ctime changes even if mtime is unchanged', async () => { + const teamName = 'snapshot-ctime-team'; + const teamDir = path.join(tempDir, teamName); + const configPath = path.join(teamDir, 'config.json'); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Before Ctime', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + let ctimeMs = 1000; + vi.spyOn(nodeFs.promises, 'stat').mockImplementation(async () => ({ + size: BigInt(4096), + mode: BigInt(33188), + dev: BigInt(1), + ino: BigInt(2), + mtimeMs: 1000, + ctimeMs, + birthtimeMs: 1000, + isFile: () => true, + }) as never); + const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile'); + + const reader = new TeamConfigReader(); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Before Ctime'); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'After Ctime', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + ctimeMs = 2000; + + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('After Ctime'); + expect(readFileSpy).toHaveBeenCalledTimes(2); + }); + + it('rereads snapshot when the config fingerprint changes', async () => { + const teamName = 'snapshot-reread-team'; + const teamDir = path.join(tempDir, teamName); + const configPath = path.join(teamDir, 'config.json'); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Before', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile'); + + const reader = new TeamConfigReader(); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Before'); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'After', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('After'); + expect(readFileSpy).toHaveBeenCalledTimes(2); + }); + + it('primeConfig updates snapshot cache immediately after app-owned writes', async () => { + const teamName = 'prime-cache-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'Before Prime', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + + const reader = new TeamConfigReader(); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Before Prime'); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'After Prime', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + await TeamConfigReader.primeConfig(teamName, { + name: 'After Prime', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + } as never); + + const snapshot = await reader.getConfigSnapshot(teamName); + expect(snapshot?.name).toBe('After Prime'); + }); + + it('does not let stale in-flight snapshot reads overwrite a primed config cache', async () => { + const teamName = 'stale-read-prime-team'; + const teamDir = path.join(tempDir, teamName); + const configPath = path.join(teamDir, 'config.json'); + await fs.mkdir(teamDir, { recursive: true }); + const staleRaw = JSON.stringify({ + name: 'Stale Read', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }); + await fs.writeFile(configPath, staleRaw, 'utf8'); + + const readDeferred = createDeferred(); + const realReadFile = nodeFs.promises.readFile.bind(nodeFs.promises); + let intercepted = false; + vi.spyOn(nodeFs.promises, 'readFile').mockImplementation( + ((file: unknown, ...args: unknown[]) => { + if (!intercepted && String(file) === configPath) { + intercepted = true; + return readDeferred.promise as never; + } + return realReadFile(file as never, ...(args as never[])) as never; + }) as never + ); + + const reader = new TeamConfigReader(); + const staleSnapshot = reader.getConfigSnapshot(teamName); + await vi.waitFor(() => expect(intercepted).toBe(true)); + + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Fresh Prime', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + await TeamConfigReader.primeConfig(teamName, { + name: 'Fresh Prime', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + } as never); + + readDeferred.resolve(staleRaw); + expect((await staleSnapshot)?.name).toBe('Stale Read'); + + vi.spyOn(nodeFs.promises, 'stat').mockRejectedValue(new Error('stat unavailable')); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Fresh Prime'); + }); + + it('does not let stale in-flight snapshot read failures invalidate a primed config cache', async () => { + const teamName = 'stale-read-failure-prime-team'; + const teamDir = path.join(tempDir, teamName); + const configPath = path.join(teamDir, 'config.json'); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Before Failure', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + + const readDeferred = createDeferred(); + const realReadFile = nodeFs.promises.readFile.bind(nodeFs.promises); + let intercepted = false; + vi.spyOn(nodeFs.promises, 'readFile').mockImplementation( + ((file: unknown, ...args: unknown[]) => { + if (!intercepted && String(file) === configPath) { + intercepted = true; + return readDeferred.promise as never; + } + return realReadFile(file as never, ...(args as never[])) as never; + }) as never + ); + + const reader = new TeamConfigReader(); + const staleSnapshot = reader.getConfigSnapshot(teamName); + await vi.waitFor(() => expect(intercepted).toBe(true)); + + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Fresh After Failure', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + await TeamConfigReader.primeConfig(teamName, { + name: 'Fresh After Failure', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + } as never); + + readDeferred.reject(new Error('old read failed')); + await expect(staleSnapshot).resolves.toBeNull(); + + vi.spyOn(nodeFs.promises, 'stat').mockRejectedValue(new Error('stat unavailable')); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Fresh After Failure'); + }); + + it('does not let stale in-flight snapshot stat results invalidate a primed config cache', async () => { + const teamName = 'stale-stat-prime-team'; + const teamDir = path.join(tempDir, teamName); + const configPath = path.join(teamDir, 'config.json'); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Before Stat Race', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + + const statDeferred = createDeferred(); + let statCalls = 0; + vi.spyOn(nodeFs.promises, 'stat').mockImplementation(async () => { + statCalls++; + if (statCalls === 1) { + return (await statDeferred.promise) as never; + } + throw new Error('stat unavailable'); + }); + + const reader = new TeamConfigReader(); + const snapshot = reader.getConfigSnapshot(teamName); + await vi.waitFor(() => expect(statCalls).toBe(1)); + + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Fresh After Stat Race', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + await TeamConfigReader.primeConfig(teamName, { + name: 'Fresh After Stat Race', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + } as never); + + statDeferred.resolve({ + size: BigInt(4096), + mode: BigInt(33188), + dev: BigInt(1), + ino: BigInt(2), + mtimeMs: 1000, + ctimeMs: 1000, + birthtimeMs: 1000, + isFile: () => false, + }); + + expect((await snapshot)?.name).toBe('Fresh After Stat Race'); + }); + + it('invalidateTeam forces the next snapshot to reread config content', async () => { + const teamName = 'invalidate-cache-team'; + const teamDir = path.join(tempDir, teamName); + const configPath = path.join(teamDir, 'config.json'); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Before Invalidate', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile'); + + const reader = new TeamConfigReader(); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Before Invalidate'); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'After Invalidate', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + TeamConfigReader.invalidateTeam(teamName); + + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('After Invalidate'); + expect(readFileSpy).toHaveBeenCalledTimes(2); + }); + + it('uses recent snapshot cache on stat failure but verified mode does not', async () => { + const teamName = 'stat-failure-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'Recent Cache', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + + const reader = new TeamConfigReader(); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Recent Cache'); + vi.spyOn(nodeFs.promises, 'stat').mockRejectedValue(new Error('stat unavailable')); + + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Recent Cache'); + await expect(reader.getConfigVerified(teamName)).resolves.toBeNull(); + }); + + it('clears snapshot cache after parse failure', async () => { + const teamName = 'parse-failure-team'; + const teamDir = path.join(tempDir, teamName); + const configPath = path.join(teamDir, 'config.json'); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Valid Config', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + + const reader = new TeamConfigReader(); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Valid Config'); + await fs.writeFile(configPath, '{"name":', 'utf8'); + + expect(await reader.getConfigSnapshot(teamName)).toBeNull(); + await fs.rm(configPath); + expect(await reader.getConfigSnapshot(teamName)).toBeNull(); + }); + + it('clears snapshot cache when config disappears and reloads after recreation', async () => { + const teamName = 'missing-then-recreated-team'; + const teamDir = path.join(tempDir, teamName); + const configPath = path.join(teamDir, 'config.json'); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Before Delete', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + + const reader = new TeamConfigReader(); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Before Delete'); + await fs.rm(configPath); + + expect(await reader.getConfigSnapshot(teamName)).toBeNull(); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'After Recreate', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('After Recreate'); + }); + + it('bounds stale snapshots on coarse fingerprints with periodic full verification', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-30T12:00:00.000Z')); + + const teamName = 'coarse-fs-team'; + const teamDir = path.join(tempDir, teamName); + const configPath = path.join(teamDir, 'config.json'); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Alpha', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile'); + vi.spyOn(nodeFs.promises, 'stat').mockResolvedValue({ + size: BigInt(4096), + mode: BigInt(33188), + dev: BigInt(1), + ino: BigInt(2), + mtimeMs: 1000, + ctimeMs: 1000, + birthtimeMs: 1000, + isFile: () => true, + } as never); + + const reader = new TeamConfigReader(); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Alpha'); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Bravo', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Alpha'); + vi.advanceTimersByTime(1_501); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Bravo'); + expect(readFileSpy).toHaveBeenCalledTimes(2); + }); }); diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 9a350f98..e828792d 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -423,6 +423,9 @@ function createGetTeamDataHarness( const getConfig = vi.fn(async () => options.config === undefined ? buildDefaultTeamConfig() : options.config ); + const getConfigSnapshot = vi.fn(async () => + options.config === undefined ? buildDefaultTeamConfig() : options.config + ); const getTasks = options.getTasks ?? (async () => { @@ -496,6 +499,7 @@ function createGetTeamDataHarness( { listTeams: vi.fn(), getConfig, + getConfigSnapshot, } as never, taskReader as never, inboxReader as never, @@ -522,6 +526,7 @@ function createGetTeamDataHarness( return { service, getConfig, + getConfigSnapshot, taskReader, inboxReader, membersMetaStore, @@ -4431,6 +4436,24 @@ describe('TeamDataService', () => { expect(harness.listProcessesSpy).not.toHaveBeenCalled(); }); + it('uses snapshot config reads for UI team data snapshots', async () => { + const harness = createGetTeamDataHarness(); + + await harness.service.getTeamData('my-team'); + + expect(harness.getConfigSnapshot).toHaveBeenCalledWith('my-team'); + expect(harness.getConfig).not.toHaveBeenCalled(); + }); + + it('uses snapshot config reads for UI message feed snapshots', async () => { + const harness = createGetTeamDataHarness(); + + await harness.service.getMessageFeed('my-team'); + + expect(harness.getConfigSnapshot).toHaveBeenCalledWith('my-team'); + expect(harness.getConfig).not.toHaveBeenCalled(); + }); + it('starts light reads immediately, bounds heavy reads, and keeps processes outside the parallel phase', async () => { const order: string[] = []; const tasksDeferred = createDeferred(); diff --git a/test/main/services/team/TeamDataWorkerClient.test.ts b/test/main/services/team/TeamDataWorkerClient.test.ts new file mode 100644 index 00000000..8628ec61 --- /dev/null +++ b/test/main/services/team/TeamDataWorkerClient.test.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const hoisted = vi.hoisted(() => { + const workers: Array<{ + messages: unknown[]; + handlers: Map void>; + postMessage: (message: unknown) => void; + on: (event: string, handler: (value: unknown) => void) => void; + terminate: () => Promise; + }> = []; + const createMockWorker = vi.fn().mockImplementation(() => { + const worker = { + messages: [] as unknown[], + handlers: new Map void>(), + postMessage(message: unknown) { + worker.messages.push(message); + const request = message as { id: string; op: string; payload?: { teamName?: string } }; + queueMicrotask(() => { + const handler = worker.handlers.get('message'); + if (!handler) return; + handler({ + id: request.id, + ok: true, + result: + request.op === 'getTeamData' + ? { teamName: request.payload?.teamName, config: { name: 'Team' } } + : null, + }); + }); + }, + 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, + }; +}); + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + existsSync: vi.fn(() => true), + }; +}); + +vi.mock('node:worker_threads', () => ({ + Worker: hoisted.createMockWorker, + default: { + Worker: hoisted.createMockWorker, + }, +})); + +describe('TeamDataWorkerClient', () => { + afterEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + hoisted.workers.length = 0; + }); + + it('deduplicates concurrent getTeamData calls for the same team', async () => { + const { TeamDataWorkerClient } = await import( + '../../../../src/main/services/team/TeamDataWorkerClient' + ); + const client = new TeamDataWorkerClient(); + + const [first, second] = await Promise.all([ + client.getTeamData('my-team'), + client.getTeamData('my-team'), + ]); + + expect(first).toEqual(second); + 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 () => { + 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.invalidateTeamConfig('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: 'invalidateTeamConfig', + payload: { teamName: 'my-team' }, + }); + + client.dispose(); + }); + + it('clears in-flight getTeamData dedupe when invalidating team config', async () => { + const { TeamDataWorkerClient } = await import( + '../../../../src/main/services/team/TeamDataWorkerClient' + ); + const client = new TeamDataWorkerClient(); + + const first = client.getTeamData('my-team'); + client.invalidateTeamConfig('my-team'); + const second = client.getTeamData('my-team'); + + await Promise.all([first, second]); + + expect(hoisted.workers).toHaveLength(1); + expect(hoisted.workers[0].messages.map((message) => (message as { op: string }).op)).toEqual([ + 'getTeamData', + 'invalidateTeamConfig', + 'getTeamData', + ]); + + client.dispose(); + }); + + it('does not spawn a worker only to send config invalidation', async () => { + const { TeamDataWorkerClient } = await import( + '../../../../src/main/services/team/TeamDataWorkerClient' + ); + const client = new TeamDataWorkerClient(); + + client.invalidateTeamConfig('my-team'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(hoisted.workers).toHaveLength(0); + }); +}); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 17b236dd..0cb1eddb 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -6463,7 +6463,7 @@ describe('TeamProvisioningService', () => { expect(writtenSnapshot?.expectedMembers).toEqual(['bob', 'alice']); }); - it('accepts duplicate OpenCode bootstrap check-ins for the same runtime session without rewriting liveness', async () => { + it('accepts duplicate OpenCode bootstrap check-ins for the same runtime session and refreshes liveness', async () => { const svc = new TeamProvisioningService(); const previousSnapshot = { version: 2 as const, @@ -6520,7 +6520,14 @@ describe('TeamProvisioningService', () => { diagnostics: ['opencode_bootstrap_checkin_duplicate_accepted'], runtimeSessionId: 'session-bob', }); - expect(updateLiveness).not.toHaveBeenCalled(); + expect(updateLiveness).toHaveBeenCalledWith( + expect.objectContaining({ + teamName: 'mixed-team', + runId: 'opencode-run-1', + memberName: 'bob', + runtimeSessionId: 'session-bob', + }) + ); }); it('rejects duplicate OpenCode bootstrap check-ins for members removed after the first check-in', async () => { @@ -6869,6 +6876,114 @@ describe('TeamProvisioningService', () => { ]); }); + it('updates the live mixed OpenCode lane when bootstrap check-in arrives after launch command completion', async () => { + const svc = new TeamProvisioningService(); + const persistLaunchStateSnapshot = vi.spyOn(svc as any, 'persistLaunchStateSnapshot'); + const teamName = 'mixed-live-checkin-team'; + const laneId = 'secondary:opencode:tom'; + const runId = 'opencode-run-tom'; + const run = createMemberSpawnRun({ + runId: 'lead-run', + teamName, + expectedMembers: ['bob', 'tom'], + memberSpawnStatuses: new Map([ + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }), + ], + [ + 'tom', + createMemberSpawnStatusEntry({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + }), + ], + ]), + }); + Object.assign(run, { + isLaunch: true, + request: { providerId: 'codex', members: [] }, + effectiveMembers: [{ name: 'bob', providerId: 'codex', model: 'gpt-5.5' }], + allEffectiveMembers: [ + { name: 'bob', providerId: 'codex', model: 'gpt-5.5' }, + { name: 'tom', providerId: 'opencode', model: 'openrouter/minimax/minimax-m2.5' }, + ], + mixedSecondaryLanes: [ + { + laneId, + providerId: 'opencode', + member: { + name: 'tom', + providerId: 'opencode', + model: 'openrouter/minimax/minimax-m2.5', + }, + runId, + state: 'finished', + result: { + runId, + teamName, + launchPhase: 'active', + teamLaunchState: 'partial_pending', + members: { + tom: { + memberName: 'tom', + providerId: 'opencode', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + livenessKind: 'registered_only', + diagnostics: ['registered runtime metadata without live process'], + }, + }, + warnings: [], + diagnostics: ['registered runtime metadata without live process'], + }, + warnings: [], + diagnostics: ['registered runtime metadata without live process'], + }, + ], + }); + (svc as any).aliveRunByTeam.set(teamName, 'lead-run'); + (svc as any).runs.set('lead-run', run); + + await (svc as any).updateOpenCodeRuntimeMemberLiveness({ + teamName, + runId, + memberName: 'tom', + runtimeSessionId: 'ses_tom_live', + observedAt: '2026-04-22T12:05:00.000Z', + diagnostics: undefined, + metadata: undefined, + reason: 'OpenCode runtime bootstrap check-in accepted', + }); + + expect(run.memberSpawnStatuses.get('tom')).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + livenessKind: 'confirmed_bootstrap', + }); + expect(run.mixedSecondaryLanes[0]?.result?.members.tom).toMatchObject({ + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + sessionId: 'ses_tom_live', + }); + expect(persistLaunchStateSnapshot).toHaveBeenCalledWith(run, 'finished'); + }); + it('uses the secondary lane run id for OpenCode runtime delivery journal acceptance', async () => { const svc = new TeamProvisioningService(); const delivered = new Map< @@ -13188,6 +13303,222 @@ describe('TeamProvisioningService', () => { }); }); + it('recovers degraded OpenCode file-lock failures when bootstrap evidence committed later', async () => { + const teamName = 'atlas-hq-file-lock-late-evidence'; + const tomLaneId = 'secondary:opencode:tom'; + const tomRunId = 'tom-runtime-run'; + writeTeamMeta(teamName, { + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.5', + }); + writeMembersMeta(teamName, [ + { name: 'bob', providerId: 'codex', model: 'gpt-5.5' }, + { name: 'jack', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'tom', providerId: 'opencode', model: 'openrouter/minimax/minimax-m2.5' }, + ]); + writeLaunchConfig(teamName, '/Users/test/proj', 'lead-session', ['bob', 'jack']); + writeBootstrapState(teamName, [ + { name: 'bob', status: 'bootstrap_confirmed', lastObservedAt: Date.now() - 60_000 }, + { name: 'jack', status: 'bootstrap_confirmed', lastObservedAt: Date.now() - 60_000 }, + ]); + fs.writeFileSync( + getTeamLaunchStatePath(teamName), + `${JSON.stringify( + createPersistedLaunchSnapshot({ + teamName, + leadSessionId: 'lead-session', + launchPhase: 'finished', + expectedMembers: ['bob', 'jack', 'tom'], + bootstrapExpectedMembers: ['bob', 'jack'], + members: { + bob: { + name: 'bob', + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'Teammate was never spawned during launch.', + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + jack: { + name: 'jack', + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'Teammate was never spawned during launch.', + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + tom: { + name: 'tom', + providerId: 'opencode', + model: 'openrouter/minimax/minimax-m2.5', + laneId: tomLaneId, + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: `File lock timeout: ${path.join( + tempTeamsBase, + teamName, + '.opencode-runtime', + 'lanes', + encodeURIComponent(tomLaneId), + 'opencode-runtime-receipts.json' + )}`, + diagnostics: ['File lock timeout: opencode-runtime-receipts.json'], + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + }, + updatedAt: '2026-04-23T10:00:00.000Z', + }), + null, + 2 + )}\n`, + 'utf8' + ); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: tomLaneId, + state: 'degraded', + diagnostics: ['File lock timeout: opencode-runtime-receipts.json'], + }); + await writeCommittedOpenCodeSessionStore({ + teamName, + laneId: tomLaneId, + runId: tomRunId, + sessions: [ + { + id: 'ses_tom_late', + teamName, + memberName: 'tom', + runId: tomRunId, + laneId: tomLaneId, + providerId: 'opencode', + observedAt: '2026-04-23T10:01:00.000Z', + source: 'runtime_bootstrap_checkin', + }, + ], + }); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + bootstrapConfirmed: true, + }); + expect(result.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + }); + expect(result.statuses.jack).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + }); + }); + + it('does not recover degraded OpenCode file-lock failures from stale run evidence', async () => { + const teamName = 'atlas-hq-file-lock-stale-run-evidence'; + const tomLaneId = 'secondary:opencode:tom'; + writeTeamMeta(teamName, { + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.5', + }); + writeMembersMeta(teamName, [ + { name: 'tom', providerId: 'opencode', model: 'openrouter/minimax/minimax-m2.5' }, + ]); + writeLaunchConfig(teamName, '/Users/test/proj', 'lead-session', []); + fs.writeFileSync( + getTeamLaunchStatePath(teamName), + `${JSON.stringify( + createPersistedLaunchSnapshot({ + teamName, + leadSessionId: 'lead-session', + launchPhase: 'finished', + expectedMembers: ['tom'], + bootstrapExpectedMembers: [], + members: { + tom: { + name: 'tom', + providerId: 'opencode', + model: 'openrouter/minimax/minimax-m2.5', + laneId: tomLaneId, + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'File lock timeout: opencode-runtime-receipts.json', + diagnostics: ['File lock timeout: opencode-runtime-receipts.json'], + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + }, + updatedAt: '2026-04-23T10:00:00.000Z', + }), + null, + 2 + )}\n`, + 'utf8' + ); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: tomLaneId, + state: 'degraded', + diagnostics: ['File lock timeout: opencode-runtime-receipts.json'], + }); + await writeCommittedOpenCodeSessionStore({ + teamName, + laneId: tomLaneId, + runId: 'current-runtime-run', + sessions: [ + { + id: 'ses_tom_old', + teamName, + memberName: 'tom', + runId: 'old-runtime-run', + laneId: tomLaneId, + providerId: 'opencode', + observedAt: '2026-04-23T09:00:00.000Z', + source: 'runtime_bootstrap_checkin', + }, + ], + }); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.statuses.tom).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + hardFailure: true, + bootstrapConfirmed: false, + }); + }); + it('reconciles stale persisted mixed pending OpenCode lanes instead of keeping them pending forever', async () => { const teamName = 'signal-ops-7'; writeTeamMeta(teamName, { diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index 90f288a1..8ccc48ac 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -83,6 +83,80 @@ describe('memberHelpers spawn-aware presence', () => { ).toBe('starting'); }); + it('labels queued OpenCode lanes separately from active startup', () => { + const openCodeMember: ResolvedTeamMember = { ...member, providerId: 'opencode' }; + + expect( + buildMemberLaunchPresentation({ + member: openCodeMember, + spawnStatus: 'waiting', + spawnLaunchState: 'starting', + spawnLivenessSource: undefined, + spawnRuntimeAlive: false, + runtimeAdvisory: undefined, + isLaunchSettling: true, + isTeamAlive: true, + isTeamProvisioning: false, + }) + ).toMatchObject({ + presenceLabel: 'queued', + launchVisualState: 'queued', + launchStatusLabel: 'queued', + dotClass: expect.stringContaining('bg-zinc-400'), + }); + }); + + it('does not label non-OpenCode waiting lanes as queued', () => { + expect( + buildMemberLaunchPresentation({ + member, + spawnStatus: 'waiting', + spawnLaunchState: 'starting', + spawnLivenessSource: undefined, + spawnRuntimeAlive: false, + runtimeAdvisory: undefined, + isLaunchSettling: true, + isTeamAlive: true, + isTeamProvisioning: false, + }) + ).toMatchObject({ + presenceLabel: 'starting', + launchVisualState: 'waiting', + launchStatusLabel: 'waiting to start', + }); + }); + + it('keeps OpenCode runtime evidence states more specific than queued', () => { + const openCodeMember: ResolvedTeamMember = { ...member, providerId: 'opencode' }; + + expect( + buildMemberLaunchPresentation({ + member: openCodeMember, + spawnStatus: 'waiting', + spawnLaunchState: 'starting', + spawnLivenessSource: undefined, + spawnRuntimeAlive: false, + runtimeEntry: { + memberName: 'alice', + alive: false, + restartable: true, + providerId: 'opencode', + livenessKind: 'registered_only', + runtimeDiagnostic: 'registered runtime metadata without live process', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + runtimeAdvisory: undefined, + isLaunchSettling: true, + isTeamAlive: true, + isTeamProvisioning: false, + }) + ).toMatchObject({ + presenceLabel: 'registered', + launchVisualState: 'registered_only', + launchStatusLabel: 'registered', + }); + }); + it('keeps starting visuals after provisioning already transitioned out of active state', () => { expect( getSpawnAwarePresenceLabel(