diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 6b84d7c6..143c76b7 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -123,6 +123,12 @@ export class ChangeExtractorService { 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( repository: TaskChangePresenceRepository, tracker: TeamLogSourceTracker @@ -671,7 +677,7 @@ export class ChangeExtractorService { try { const [meta, config] = await Promise.all([ this.teamMetaStore.getMeta(teamName).catch(() => null), - this.configReader.getConfig(teamName).catch(() => null), + this.readConfigForObservation(teamName).catch(() => null), ]); const hasOpenCodeMember = (config?.members ?? []).some( (member) => member.providerId === 'opencode' @@ -996,7 +1002,7 @@ export class ChangeExtractorService { /** Получить projectPath из конфига команды */ private async resolveProjectPath(teamName: string): Promise { try { - const config = await this.configReader.getConfig(teamName); + const config = await this.readConfigForObservation(teamName); return config?.projectPath?.trim() || undefined; } catch { return undefined; diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 3a366b93..ca226987 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -1898,7 +1898,7 @@ export class TeamDataService { let projectPath: string | undefined; try { - const config = await this.configReader.getConfig(teamName); + const config = await readConfigForUiSnapshot(this.configReader, teamName); projectPath = config?.projectPath; } catch { /* best-effort */ @@ -2237,7 +2237,7 @@ export class TeamDataService { let enrichedRequest = request; if (!enrichedRequest.leadSessionId) { try { - const config = await this.configReader.getConfig(teamName); + const config = await readConfigForUiSnapshot(this.configReader, teamName); if (config?.leadSessionId) { enrichedRequest = { ...enrichedRequest, leadSessionId: config.leadSessionId }; } @@ -2310,7 +2310,7 @@ export class TeamDataService { private async resolveLeadName(teamName: string): Promise { try { - const config = await this.configReader.getConfig(teamName); + const config = await readConfigForUiSnapshot(this.configReader, teamName); return this.resolveLeadNameFromConfig(config); } catch { return 'team-lead'; @@ -2321,7 +2321,7 @@ export class TeamDataService { teamName: string ): Promise<{ leadName: string; leadSessionId?: string }> { try { - const config = await this.configReader.getConfig(teamName); + const config = await readConfigForUiSnapshot(this.configReader, teamName); return { leadName: this.resolveLeadNameFromConfig(config), leadSessionId: config?.leadSessionId, @@ -2638,7 +2638,7 @@ export class TeamDataService { const recoverPending = options?.recoverPending === true; let config: TeamConfig | null = null; try { - config = await this.configReader.getConfig(teamName); + config = await readConfigForUiSnapshot(this.configReader, teamName); } catch { return; } @@ -2793,7 +2793,7 @@ export class TeamDataService { ): Promise { let leadSessionId: string | undefined; try { - const config = await this.configReader.getConfig(teamName); + const config = await readConfigForUiSnapshot(this.configReader, teamName); leadSessionId = config?.leadSessionId; } catch { // non-critical — proceed without sessionId @@ -2826,7 +2826,7 @@ export class TeamDataService { async getLeadMemberName(teamName: string): Promise { try { - const config = await this.configReader.getConfig(teamName); + const config = await readConfigForUiSnapshot(this.configReader, teamName); // Check config.json members first (Claude Code-created teams) if (config?.members?.length) { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 05336ee6..0aae9695 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -4435,6 +4435,27 @@ interface OpenCodeMemberInboxDelivery { diagnostics?: string[]; } +interface OpenCodeMemberDirectory { + config: TeamConfig | null; + teamMeta: Awaited> | null; + metaMembers: Awaited>; +} + +type OpenCodeMemberIdentityResolution = + | { + ok: true; + canonicalMemberName: string; + laneId: string; + laneIdentity: ReturnType; + configMember?: TeamMember; + metaMember?: TeamMember; + memberRuntimeCwd?: string; + } + | { + ok: false; + reason: 'recipient_is_not_opencode' | 'recipient_removed' | 'opencode_recipient_unavailable'; + }; + interface OpenCodeMemberInboxRelayResult { relayed: number; attempted: number; @@ -4613,6 +4634,106 @@ export class TeamProvisioningService { : configReader.getConfig(teamName); } + private readConfigForObservation(teamName: string): Promise { + return this.readConfigSnapshot(teamName); + } + + private readConfigForStrictDecision(teamName: string): Promise { + return this.configReader.getConfig(teamName); + } + + private async readOpenCodeMemberDirectory(teamName: string): Promise { + const [config, teamMeta, metaMembers] = await Promise.all([ + this.readConfigForObservation(teamName).catch(() => null), + this.teamMetaStore.getMeta(teamName).catch(() => null), + this.membersMetaStore.getMembers(teamName).catch(() => []), + ]); + return { config, teamMeta, metaMembers }; + } + + private resolveOpenCodeMemberIdentityFromDirectory( + teamName: string, + memberName: string, + directory: OpenCodeMemberDirectory + ): OpenCodeMemberIdentityResolution { + const normalizedMemberName = memberName.trim(); + const configMember = directory.config?.members?.find( + (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() + ); + const metaMember = directory.metaMembers.find( + (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() + ); + if (!configMember && !metaMember) { + return { ok: false, reason: 'opencode_recipient_unavailable' }; + } + + const configProvider = (configMember as { provider?: unknown } | undefined)?.provider; + const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider; + const providerId = + normalizeTeamProviderLike(metaMember?.providerId) ?? + normalizeTeamProviderLike(metaProvider) ?? + normalizeTeamProviderLike(configMember?.providerId) ?? + normalizeTeamProviderLike(configProvider) ?? + inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model); + if (providerId !== 'opencode') { + return { ok: false, reason: 'recipient_is_not_opencode' }; + } + + const removedAt = + metaMember != null + ? metaMember.removedAt + : (configMember as { removedAt?: unknown } | undefined)?.removedAt; + if (removedAt != null) { + return { ok: false, reason: 'recipient_removed' }; + } + + const canonicalMemberName = + metaMember?.name?.trim() || configMember?.name?.trim() || normalizedMemberName; + const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName); + if (runtimeRun?.providerId === 'opencode') { + const laneIdentity = buildPlannedMemberLaneIdentity({ + leadProviderId: 'opencode', + member: { + name: canonicalMemberName, + providerId: 'opencode', + }, + }); + const memberRuntimeCwd = metaMember?.cwd?.trim() || configMember?.cwd?.trim(); + return { + ok: true, + canonicalMemberName, + laneId: laneIdentity.laneId, + laneIdentity, + ...(configMember ? { configMember } : {}), + ...(metaMember ? { metaMember } : {}), + ...(memberRuntimeCwd ? { memberRuntimeCwd } : {}), + }; + } + + const leadMember = directory.config?.members?.find((member) => isLeadMember(member)); + const leadProviderId = + normalizeOptionalTeamProviderId(directory.teamMeta?.launchIdentity?.providerId) ?? + normalizeOptionalTeamProviderId(directory.teamMeta?.providerId) ?? + normalizeOptionalTeamProviderId(leadMember?.providerId); + const laneIdentity = buildPlannedMemberLaneIdentity({ + leadProviderId, + member: { + name: canonicalMemberName, + providerId, + }, + }); + const memberRuntimeCwd = metaMember?.cwd?.trim() || configMember?.cwd?.trim(); + return { + ok: true, + canonicalMemberName, + laneId: laneIdentity.laneId, + laneIdentity, + ...(configMember ? { configMember } : {}), + ...(metaMember ? { metaMember } : {}), + ...(memberRuntimeCwd ? { memberRuntimeCwd } : {}), + }; + } + setRuntimeAdapterRegistry(registry: TeamRuntimeAdapterRegistry | null): void { this.runtimeAdapterRegistry = registry; } @@ -5283,7 +5404,7 @@ export class TeamProvisioningService { const adapter = this.getOpenCodeRuntimeAdapter(); const previousLaunchState = await this.launchStateStore.read(teamName).catch(() => null); const [config, metaMembers] = await Promise.all([ - this.configReader.getConfig(teamName).catch(() => null), + this.readConfigForObservation(teamName).catch(() => null), this.membersMetaStore.getMembers(teamName).catch(() => []), ]); const evidenceReader = new OpenCodeRuntimeManifestEvidenceReader({ @@ -5813,8 +5934,7 @@ export class TeamProvisioningService { }): Promise { const explicitRecipient = input.replyRecipient?.trim() || 'user'; const candidates = [explicitRecipient]; - const configuredLeadName = await this.configReader - .getConfig(input.teamName) + const configuredLeadName = await this.readConfigForObservation(input.teamName) .then( (config) => config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null ) @@ -6236,51 +6356,25 @@ export class TeamProvisioningService { if (!adapter) { return { delivered: false, reason: 'opencode_runtime_message_bridge_unavailable' }; } - const [config, teamMeta, metaMembers] = await Promise.all([ - this.configReader.getConfig(teamName).catch(() => null), - this.teamMetaStore.getMeta(teamName).catch(() => null), - this.membersMetaStore.getMembers(teamName).catch(() => []), - ]); + const directory = await this.readOpenCodeMemberDirectory(teamName); + const identity = this.resolveOpenCodeMemberIdentityFromDirectory( + teamName, + input.memberName, + directory + ); + if (!identity.ok) { + return { + delivered: false, + reason: + identity.reason === 'opencode_recipient_unavailable' + ? 'recipient_is_not_opencode' + : identity.reason, + }; + } + const { config } = directory; + const { canonicalMemberName, laneIdentity, configMember, metaMember, memberRuntimeCwd } = + identity; const normalizedMemberName = input.memberName.trim(); - const configMember = config?.members?.find( - (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() - ); - const metaMember = metaMembers.find( - (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() - ); - const configProvider = (configMember as { provider?: unknown } | undefined)?.provider; - const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider; - const providerId = - normalizeTeamProviderLike(metaMember?.providerId) ?? - normalizeTeamProviderLike(metaProvider) ?? - normalizeTeamProviderLike(configMember?.providerId) ?? - normalizeTeamProviderLike(configProvider) ?? - inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model); - if (providerId !== 'opencode') { - return { delivered: false, reason: 'recipient_is_not_opencode' }; - } - const removedAt = - metaMember != null - ? metaMember.removedAt - : (configMember as { removedAt?: unknown } | undefined)?.removedAt; - if (removedAt != null) { - return { delivered: false, reason: 'recipient_removed' }; - } - const canonicalMemberName = - metaMember?.name?.trim() || configMember?.name?.trim() || normalizedMemberName; - - const leadMember = config?.members?.find((member) => isLeadMember(member)); - const leadProviderId = - normalizeOptionalTeamProviderId(teamMeta?.launchIdentity?.providerId) ?? - normalizeOptionalTeamProviderId(teamMeta?.providerId) ?? - normalizeOptionalTeamProviderId(leadMember?.providerId); - const laneIdentity = buildPlannedMemberLaneIdentity({ - leadProviderId, - member: { - name: canonicalMemberName, - providerId, - }, - }); if ( laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode' && @@ -6288,7 +6382,6 @@ export class TeamProvisioningService { ) { return { delivered: false, reason: 'opencode_runtime_not_active' }; } - const memberRuntimeCwd = metaMember?.cwd?.trim() || configMember?.cwd?.trim(); const cwd = laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode' ? memberRuntimeCwd || @@ -7150,64 +7243,18 @@ export class TeamProvisioningService { | 'opencode_recipient_unavailable'; } > { - const [config, teamMeta, metaMembers] = await Promise.all([ - this.configReader.getConfig(teamName).catch(() => null), - this.teamMetaStore.getMeta(teamName).catch(() => null), - this.membersMetaStore.getMembers(teamName).catch(() => []), - ]); - const normalizedMemberName = memberName.trim(); - const configMember = config?.members?.find( - (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() + const directory = await this.readOpenCodeMemberDirectory(teamName); + const laneIdentity = this.resolveOpenCodeMemberIdentityFromDirectory( + teamName, + memberName, + directory ); - const metaMember = metaMembers.find( - (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() - ); - if (!configMember && !metaMember) { - return { ok: false, reason: 'opencode_recipient_unavailable' }; + if (!laneIdentity.ok) { + return laneIdentity; } - const configProvider = (configMember as { provider?: unknown } | undefined)?.provider; - const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider; - const providerId = - normalizeTeamProviderLike(metaMember?.providerId) ?? - normalizeTeamProviderLike(metaProvider) ?? - normalizeTeamProviderLike(configMember?.providerId) ?? - normalizeTeamProviderLike(configProvider) ?? - inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model); - if (providerId !== 'opencode') { - return { ok: false, reason: 'recipient_is_not_opencode' }; - } - const removedAt = - metaMember != null - ? metaMember.removedAt - : (configMember as { removedAt?: unknown } | undefined)?.removedAt; - if (removedAt != null) { - return { ok: false, reason: 'recipient_removed' }; - } - const canonicalMemberName = - metaMember?.name?.trim() || configMember?.name?.trim() || normalizedMemberName; - const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName); - if (runtimeRun?.providerId === 'opencode') { - return { - ok: true, - canonicalMemberName, - laneId: 'primary', - }; - } - const leadMember = config?.members?.find((member) => isLeadMember(member)); - const leadProviderId = - normalizeOptionalTeamProviderId(teamMeta?.launchIdentity?.providerId) ?? - normalizeOptionalTeamProviderId(teamMeta?.providerId) ?? - normalizeOptionalTeamProviderId(leadMember?.providerId); - const laneIdentity = buildPlannedMemberLaneIdentity({ - leadProviderId, - member: { - name: canonicalMemberName, - providerId, - }, - }); return { ok: true, - canonicalMemberName, + canonicalMemberName: laneIdentity.canonicalMemberName, laneId: laneIdentity.laneId, }; } @@ -7216,24 +7263,21 @@ export class TeamProvisioningService { teamName: string, laneId: string ): Promise { - const [config, metaMembers] = await Promise.all([ - this.configReader.getConfig(teamName).catch(() => null), - this.membersMetaStore.getMembers(teamName).catch(() => []), - ]); + const directory = await this.readOpenCodeMemberDirectory(teamName); const names = new Set(); - for (const member of config?.members ?? []) { + for (const member of directory.config?.members ?? []) { if (member.name?.trim()) { names.add(member.name.trim()); } } - for (const member of metaMembers) { + for (const member of directory.metaMembers) { if (member.name?.trim()) { names.add(member.name.trim()); } } const resolved: string[] = []; for (const name of names) { - const identity = await this.resolveOpenCodeMemberDeliveryIdentity(teamName, name); + const identity = this.resolveOpenCodeMemberIdentityFromDirectory(teamName, name, directory); if (identity.ok && identity.laneId === laneId) { resolved.push(identity.canonicalMemberName); } @@ -7306,7 +7350,7 @@ export class TeamProvisioningService { } const [config, teamMeta, metaMembers, currentLaneIndex] = await Promise.all([ - this.configReader.getConfig(teamName).catch(() => null), + this.readConfigForObservation(teamName).catch(() => null), this.teamMetaStore.getMeta(teamName).catch(() => null), this.membersMetaStore.getMembers(teamName).catch(() => []), readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch(() => null), @@ -8831,7 +8875,7 @@ export class TeamProvisioningService { memberName: string; previousMember?: PersistedTeamLaunchMemberState; }): Promise { - const config = await this.configReader.getConfig(input.teamName).catch(() => null); + const config = await this.readConfigForStrictDecision(input.teamName).catch(() => null); const metaMembers = await this.membersMetaStore.getMembers(input.teamName).catch(() => []); const configuredMember = this.resolveEffectiveConfiguredMember( config?.members ?? [], @@ -10886,7 +10930,7 @@ export class TeamProvisioningService { metaMembers: Awaited>; configuredMember: ReturnType; }> => { - const config = await this.configReader.getConfig(teamName); + const config = await this.readConfigForStrictDecision(teamName); const configuredMembers = config?.members ?? []; let metaMembers: Awaited> = []; try { @@ -11202,7 +11246,7 @@ export class TeamProvisioningService { throw new Error('Member name is required'); } - const config = await this.configReader.getConfig(teamName); + const config = await this.readConfigForStrictDecision(teamName); if (!config) { throw new Error(`Team "${teamName}" configuration is no longer available`); } @@ -11362,7 +11406,7 @@ export class TeamProvisioningService { throw new Error('OpenCode runtime adapter is not available for controlled lane reattach.'); } - const config = await this.configReader.getConfig(teamName); + const config = await this.readConfigForStrictDecision(teamName); if (!config) { throw new Error(`Team "${teamName}" configuration is no longer available`); } @@ -16255,7 +16299,7 @@ export class TeamProvisioningService { // as read after native delivery, so we must scan ALL messages (including read). let config: Awaited> | null = null; try { - config = await this.configReader.getConfig(teamName); + config = await this.readConfigForObservation(teamName); } catch { // config not ready yet during early provisioning — skip scan } @@ -16304,7 +16348,7 @@ export class TeamProvisioningService { // Re-read config if needed (already fetched above but guard provisioningComplete path) if (!config) { try { - config = await this.configReader.getConfig(teamName); + config = await this.readConfigForObservation(teamName); } catch { return 0; } @@ -16782,7 +16826,7 @@ export class TeamProvisioningService { for (const teamName of aliveTeams) { try { - const config = await this.configReader.getConfig(teamName); + const config = await this.readConfigForStrictDecision(teamName); if (!config) continue; const oldCode = config.language || 'system'; @@ -21962,7 +22006,7 @@ export class TeamProvisioningService { let currentMembers: TeamCreateRequest['members'] = run.request.members; let leadName = 'team-lead'; try { - const config = await this.configReader.getConfig(run.teamName); + const config = await this.readConfigForObservation(run.teamName); if (config?.members) { const configLead = config.members.find((m) => isLeadMember(m)); leadName = configLead?.name?.trim() || 'team-lead'; @@ -22122,7 +22166,7 @@ export class TeamProvisioningService { let leadName = run.effectiveMembers.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; try { - const config = await this.configReader.getConfig(run.teamName); + const config = await this.readConfigForObservation(run.teamName); if (config?.members) { const configLead = config.members.find((m) => isLeadMember(m)); leadName = configLead?.name?.trim() || leadName; @@ -22833,7 +22877,7 @@ export class TeamProvisioningService { // Resolve project cwd from team config let projectCwd: string | undefined; try { - const config = await this.configReader.getConfig(run.teamName); + const config = await this.readConfigForStrictDecision(run.teamName); projectCwd = config?.projectPath ?? config?.members?.[0]?.cwd; } catch { // best-effort diff --git a/src/main/services/team/TeamTranscriptProjectResolver.ts b/src/main/services/team/TeamTranscriptProjectResolver.ts index cee44f65..dc4c0cfe 100644 --- a/src/main/services/team/TeamTranscriptProjectResolver.ts +++ b/src/main/services/team/TeamTranscriptProjectResolver.ts @@ -46,6 +46,11 @@ interface SessionProjectMatch extends ProjectDirCandidate { matchedSessionId: string; } +interface TeamTranscriptProjectConfigReader { + getConfig(teamName: string): Promise; + getConfigSnapshot?: (teamName: string) => Promise; +} + type ScannedSessionProjectMatch = Omit & { projectPath?: string; }; @@ -187,9 +192,15 @@ export class TeamTranscriptProjectResolver { >(); constructor( - private readonly configReader: Pick = new TeamConfigReader() + private readonly configReader: TeamTranscriptProjectConfigReader = new TeamConfigReader() ) {} + private readConfigForObservation(teamName: string): Promise { + return typeof this.configReader.getConfigSnapshot === 'function' + ? this.configReader.getConfigSnapshot(teamName) + : this.configReader.getConfig(teamName); + } + async getContext( teamName: string, options?: { forceRefresh?: boolean } @@ -203,7 +214,7 @@ export class TeamTranscriptProjectResolver { return cached.value; } - const config = await this.configReader.getConfig(teamName); + const config = await this.readConfigForObservation(teamName); if (!config) { return null; } diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index 2789c37f..b7f973dc 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -1669,6 +1669,12 @@ export class BoardTaskLogStreamService { 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 { return `${teamName}::${taskId}`; } @@ -2199,7 +2205,7 @@ export class BoardTaskLogStreamService { this.taskReader.getTasks(teamName).catch(() => []), this.taskReader.getDeletedTasks(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 ownerName = task?.owner?.trim(); diff --git a/src/main/services/team/taskLogs/stream/CodexNativeTaskLogStreamSource.ts b/src/main/services/team/taskLogs/stream/CodexNativeTaskLogStreamSource.ts index 10148c5e..74a051df 100644 --- a/src/main/services/team/taskLogs/stream/CodexNativeTaskLogStreamSource.ts +++ b/src/main/services/team/taskLogs/stream/CodexNativeTaskLogStreamSource.ts @@ -54,6 +54,12 @@ export class CodexNativeTaskLogStreamSource { 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( teamName: string, taskId: string, @@ -163,7 +169,7 @@ export class CodexNativeTaskLogStreamSource { const normalizedOwner = normalizeMemberName(ownerName); const [metaMembers, config] = await Promise.all([ this.membersMetaStore.getMembers(teamName).catch(() => []), - this.configReader.getConfig(teamName).catch(() => null), + this.readConfigForObservation(teamName).catch(() => null), ]); const member = [...metaMembers, ...(config?.members ?? [])].find( (candidate) => normalizeMemberName(candidate.name) === normalizedOwner diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 424ffc9e..441f7d2e 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -339,6 +339,35 @@ describe('TeamDataService task projection cache invalidation', () => { 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', () => { @@ -1370,15 +1399,20 @@ describe('TeamDataService', () => { it('includes projectPath from config when creating a task', async () => { 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( { listTeams: vi.fn(), - getConfig: vi.fn(async () => ({ - name: 'My team', - members: [], - projectPath: '/Users/dev/my-project', - })), + getConfig, + getConfigSnapshot, } as never, { getNextTaskId: vi.fn(async () => '1'), @@ -1417,6 +1451,8 @@ describe('TeamDataService', () => { expect(createTaskMock).toHaveBeenCalledWith( 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 () => { diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 2e79fad5..e0f21a28 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -3439,6 +3439,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) => ({ + 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 () => { const svc = new TeamProvisioningService(); const sendMessageToMember = vi.fn(async (input: Record) => ({ @@ -6531,6 +6636,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 () => { const svc = new TeamProvisioningService(); const previousSnapshot = { @@ -9664,11 +9796,16 @@ describe('TeamProvisioningService', () => { it('expands teammate permission suggestions to the operational tool set only', async () => { 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({ - getConfig: vi.fn(async () => ({ - projectPath: tempClaudeRoot, - members: [{ cwd: tempClaudeRoot }], - })), + getConfig, + getConfigSnapshot, } as any); await (svc as any).respondToTeammatePermission( @@ -9696,6 +9833,8 @@ describe('TeamProvisioningService', () => { ); expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__team_stop'); 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 () => { diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index af7aab5a..4773a70e 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -307,6 +307,45 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { 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', () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; diff --git a/test/main/services/team/TeamTranscriptProjectResolver.test.ts b/test/main/services/team/TeamTranscriptProjectResolver.test.ts index e914942e..bcf81a67 100644 --- a/test/main/services/team/TeamTranscriptProjectResolver.test.ts +++ b/test/main/services/team/TeamTranscriptProjectResolver.test.ts @@ -140,6 +140,30 @@ describe('TeamTranscriptProjectResolver', () => { 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 () => { await setupClaudeRoot();