perf(team): reduce verified config reads

This commit is contained in:
777genius 2026-05-02 21:29:22 +03:00
parent 4385b0c679
commit a652c44794
10 changed files with 453 additions and 142 deletions

View file

@ -123,6 +123,12 @@ export class ChangeExtractorService {
this.taskChangeComputer = new TaskChangeComputer(logsFinder, boundaryParser); this.taskChangeComputer = new TaskChangeComputer(logsFinder, boundaryParser);
} }
private readConfigForObservation(teamName: string) {
return typeof this.configReader.getConfigSnapshot === 'function'
? this.configReader.getConfigSnapshot(teamName)
: this.configReader.getConfig(teamName);
}
setTaskChangePresenceServices( setTaskChangePresenceServices(
repository: TaskChangePresenceRepository, repository: TaskChangePresenceRepository,
tracker: TeamLogSourceTracker tracker: TeamLogSourceTracker
@ -671,7 +677,7 @@ export class ChangeExtractorService {
try { try {
const [meta, config] = await Promise.all([ const [meta, config] = await Promise.all([
this.teamMetaStore.getMeta(teamName).catch(() => null), this.teamMetaStore.getMeta(teamName).catch(() => null),
this.configReader.getConfig(teamName).catch(() => null), this.readConfigForObservation(teamName).catch(() => null),
]); ]);
const hasOpenCodeMember = (config?.members ?? []).some( const hasOpenCodeMember = (config?.members ?? []).some(
(member) => member.providerId === 'opencode' (member) => member.providerId === 'opencode'
@ -996,7 +1002,7 @@ export class ChangeExtractorService {
/** Получить projectPath из конфига команды */ /** Получить projectPath из конфига команды */
private async resolveProjectPath(teamName: string): Promise<string | undefined> { private async resolveProjectPath(teamName: string): Promise<string | undefined> {
try { try {
const config = await this.configReader.getConfig(teamName); const config = await this.readConfigForObservation(teamName);
return config?.projectPath?.trim() || undefined; return config?.projectPath?.trim() || undefined;
} catch { } catch {
return undefined; return undefined;

View file

@ -1898,7 +1898,7 @@ export class TeamDataService {
let projectPath: string | undefined; let projectPath: string | undefined;
try { try {
const config = await this.configReader.getConfig(teamName); const config = await readConfigForUiSnapshot(this.configReader, teamName);
projectPath = config?.projectPath; projectPath = config?.projectPath;
} catch { } catch {
/* best-effort */ /* best-effort */
@ -2237,7 +2237,7 @@ export class TeamDataService {
let enrichedRequest = request; let enrichedRequest = request;
if (!enrichedRequest.leadSessionId) { if (!enrichedRequest.leadSessionId) {
try { try {
const config = await this.configReader.getConfig(teamName); const config = await readConfigForUiSnapshot(this.configReader, teamName);
if (config?.leadSessionId) { if (config?.leadSessionId) {
enrichedRequest = { ...enrichedRequest, leadSessionId: config.leadSessionId }; enrichedRequest = { ...enrichedRequest, leadSessionId: config.leadSessionId };
} }
@ -2310,7 +2310,7 @@ export class TeamDataService {
private async resolveLeadName(teamName: string): Promise<string> { private async resolveLeadName(teamName: string): Promise<string> {
try { try {
const config = await this.configReader.getConfig(teamName); const config = await readConfigForUiSnapshot(this.configReader, teamName);
return this.resolveLeadNameFromConfig(config); return this.resolveLeadNameFromConfig(config);
} catch { } catch {
return 'team-lead'; return 'team-lead';
@ -2321,7 +2321,7 @@ export class TeamDataService {
teamName: string teamName: string
): Promise<{ leadName: string; leadSessionId?: string }> { ): Promise<{ leadName: string; leadSessionId?: string }> {
try { try {
const config = await this.configReader.getConfig(teamName); const config = await readConfigForUiSnapshot(this.configReader, teamName);
return { return {
leadName: this.resolveLeadNameFromConfig(config), leadName: this.resolveLeadNameFromConfig(config),
leadSessionId: config?.leadSessionId, leadSessionId: config?.leadSessionId,
@ -2638,7 +2638,7 @@ export class TeamDataService {
const recoverPending = options?.recoverPending === true; const recoverPending = options?.recoverPending === true;
let config: TeamConfig | null = null; let config: TeamConfig | null = null;
try { try {
config = await this.configReader.getConfig(teamName); config = await readConfigForUiSnapshot(this.configReader, teamName);
} catch { } catch {
return; return;
} }
@ -2793,7 +2793,7 @@ export class TeamDataService {
): Promise<SendMessageResult> { ): Promise<SendMessageResult> {
let leadSessionId: string | undefined; let leadSessionId: string | undefined;
try { try {
const config = await this.configReader.getConfig(teamName); const config = await readConfigForUiSnapshot(this.configReader, teamName);
leadSessionId = config?.leadSessionId; leadSessionId = config?.leadSessionId;
} catch { } catch {
// non-critical — proceed without sessionId // non-critical — proceed without sessionId
@ -2826,7 +2826,7 @@ export class TeamDataService {
async getLeadMemberName(teamName: string): Promise<string | null> { async getLeadMemberName(teamName: string): Promise<string | null> {
try { try {
const config = await this.configReader.getConfig(teamName); const config = await readConfigForUiSnapshot(this.configReader, teamName);
// Check config.json members first (Claude Code-created teams) // Check config.json members first (Claude Code-created teams)
if (config?.members?.length) { if (config?.members?.length) {

View file

@ -4435,6 +4435,27 @@ interface OpenCodeMemberInboxDelivery {
diagnostics?: string[]; diagnostics?: string[];
} }
interface OpenCodeMemberDirectory {
config: TeamConfig | null;
teamMeta: Awaited<ReturnType<TeamMetaStore['getMeta']>> | null;
metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>>;
}
type OpenCodeMemberIdentityResolution =
| {
ok: true;
canonicalMemberName: string;
laneId: string;
laneIdentity: ReturnType<typeof buildPlannedMemberLaneIdentity>;
configMember?: TeamMember;
metaMember?: TeamMember;
memberRuntimeCwd?: string;
}
| {
ok: false;
reason: 'recipient_is_not_opencode' | 'recipient_removed' | 'opencode_recipient_unavailable';
};
interface OpenCodeMemberInboxRelayResult { interface OpenCodeMemberInboxRelayResult {
relayed: number; relayed: number;
attempted: number; attempted: number;
@ -4613,6 +4634,106 @@ export class TeamProvisioningService {
: configReader.getConfig(teamName); : configReader.getConfig(teamName);
} }
private readConfigForObservation(teamName: string): Promise<TeamConfig | null> {
return this.readConfigSnapshot(teamName);
}
private readConfigForStrictDecision(teamName: string): Promise<TeamConfig | null> {
return this.configReader.getConfig(teamName);
}
private async readOpenCodeMemberDirectory(teamName: string): Promise<OpenCodeMemberDirectory> {
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 { setRuntimeAdapterRegistry(registry: TeamRuntimeAdapterRegistry | null): void {
this.runtimeAdapterRegistry = registry; this.runtimeAdapterRegistry = registry;
} }
@ -5283,7 +5404,7 @@ export class TeamProvisioningService {
const adapter = this.getOpenCodeRuntimeAdapter(); const adapter = this.getOpenCodeRuntimeAdapter();
const previousLaunchState = await this.launchStateStore.read(teamName).catch(() => null); const previousLaunchState = await this.launchStateStore.read(teamName).catch(() => null);
const [config, metaMembers] = await Promise.all([ const [config, metaMembers] = await Promise.all([
this.configReader.getConfig(teamName).catch(() => null), this.readConfigForObservation(teamName).catch(() => null),
this.membersMetaStore.getMembers(teamName).catch(() => []), this.membersMetaStore.getMembers(teamName).catch(() => []),
]); ]);
const evidenceReader = new OpenCodeRuntimeManifestEvidenceReader({ const evidenceReader = new OpenCodeRuntimeManifestEvidenceReader({
@ -5813,8 +5934,7 @@ export class TeamProvisioningService {
}): Promise<string[]> { }): Promise<string[]> {
const explicitRecipient = input.replyRecipient?.trim() || 'user'; const explicitRecipient = input.replyRecipient?.trim() || 'user';
const candidates = [explicitRecipient]; const candidates = [explicitRecipient];
const configuredLeadName = await this.configReader const configuredLeadName = await this.readConfigForObservation(input.teamName)
.getConfig(input.teamName)
.then( .then(
(config) => config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null (config) => config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null
) )
@ -6236,51 +6356,25 @@ export class TeamProvisioningService {
if (!adapter) { if (!adapter) {
return { delivered: false, reason: 'opencode_runtime_message_bridge_unavailable' }; return { delivered: false, reason: 'opencode_runtime_message_bridge_unavailable' };
} }
const [config, teamMeta, metaMembers] = await Promise.all([ const directory = await this.readOpenCodeMemberDirectory(teamName);
this.configReader.getConfig(teamName).catch(() => null), const identity = this.resolveOpenCodeMemberIdentityFromDirectory(
this.teamMetaStore.getMeta(teamName).catch(() => null), teamName,
this.membersMetaStore.getMembers(teamName).catch(() => []), 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 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 ( if (
laneIdentity.laneKind === 'secondary' && laneIdentity.laneKind === 'secondary' &&
laneIdentity.laneOwnerProviderId === 'opencode' && laneIdentity.laneOwnerProviderId === 'opencode' &&
@ -6288,7 +6382,6 @@ export class TeamProvisioningService {
) { ) {
return { delivered: false, reason: 'opencode_runtime_not_active' }; return { delivered: false, reason: 'opencode_runtime_not_active' };
} }
const memberRuntimeCwd = metaMember?.cwd?.trim() || configMember?.cwd?.trim();
const cwd = const cwd =
laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode' laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode'
? memberRuntimeCwd || ? memberRuntimeCwd ||
@ -7150,64 +7243,18 @@ export class TeamProvisioningService {
| 'opencode_recipient_unavailable'; | 'opencode_recipient_unavailable';
} }
> { > {
const [config, teamMeta, metaMembers] = await Promise.all([ const directory = await this.readOpenCodeMemberDirectory(teamName);
this.configReader.getConfig(teamName).catch(() => null), const laneIdentity = this.resolveOpenCodeMemberIdentityFromDirectory(
this.teamMetaStore.getMeta(teamName).catch(() => null), teamName,
this.membersMetaStore.getMembers(teamName).catch(() => []), memberName,
]); directory
const normalizedMemberName = memberName.trim();
const configMember = config?.members?.find(
(member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase()
); );
const metaMember = metaMembers.find( if (!laneIdentity.ok) {
(member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() return laneIdentity;
);
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') {
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 { return {
ok: true, ok: true,
canonicalMemberName, canonicalMemberName: laneIdentity.canonicalMemberName,
laneId: laneIdentity.laneId, laneId: laneIdentity.laneId,
}; };
} }
@ -7216,24 +7263,21 @@ export class TeamProvisioningService {
teamName: string, teamName: string,
laneId: string laneId: string
): Promise<string[]> { ): Promise<string[]> {
const [config, metaMembers] = await Promise.all([ const directory = await this.readOpenCodeMemberDirectory(teamName);
this.configReader.getConfig(teamName).catch(() => null),
this.membersMetaStore.getMembers(teamName).catch(() => []),
]);
const names = new Set<string>(); const names = new Set<string>();
for (const member of config?.members ?? []) { for (const member of directory.config?.members ?? []) {
if (member.name?.trim()) { if (member.name?.trim()) {
names.add(member.name.trim()); names.add(member.name.trim());
} }
} }
for (const member of metaMembers) { for (const member of directory.metaMembers) {
if (member.name?.trim()) { if (member.name?.trim()) {
names.add(member.name.trim()); names.add(member.name.trim());
} }
} }
const resolved: string[] = []; const resolved: string[] = [];
for (const name of names) { 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) { if (identity.ok && identity.laneId === laneId) {
resolved.push(identity.canonicalMemberName); resolved.push(identity.canonicalMemberName);
} }
@ -7306,7 +7350,7 @@ export class TeamProvisioningService {
} }
const [config, teamMeta, metaMembers, currentLaneIndex] = await Promise.all([ 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.teamMetaStore.getMeta(teamName).catch(() => null),
this.membersMetaStore.getMembers(teamName).catch(() => []), this.membersMetaStore.getMembers(teamName).catch(() => []),
readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch(() => null), readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch(() => null),
@ -8831,7 +8875,7 @@ export class TeamProvisioningService {
memberName: string; memberName: string;
previousMember?: PersistedTeamLaunchMemberState; previousMember?: PersistedTeamLaunchMemberState;
}): Promise<void> { }): Promise<void> {
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 metaMembers = await this.membersMetaStore.getMembers(input.teamName).catch(() => []);
const configuredMember = this.resolveEffectiveConfiguredMember( const configuredMember = this.resolveEffectiveConfiguredMember(
config?.members ?? [], config?.members ?? [],
@ -10886,7 +10930,7 @@ export class TeamProvisioningService {
metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>>; metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>>;
configuredMember: ReturnType<TeamProvisioningService['resolveEffectiveConfiguredMember']>; configuredMember: ReturnType<TeamProvisioningService['resolveEffectiveConfiguredMember']>;
}> => { }> => {
const config = await this.configReader.getConfig(teamName); const config = await this.readConfigForStrictDecision(teamName);
const configuredMembers = config?.members ?? []; const configuredMembers = config?.members ?? [];
let metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>> = []; let metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>> = [];
try { try {
@ -11202,7 +11246,7 @@ export class TeamProvisioningService {
throw new Error('Member name is required'); throw new Error('Member name is required');
} }
const config = await this.configReader.getConfig(teamName); const config = await this.readConfigForStrictDecision(teamName);
if (!config) { if (!config) {
throw new Error(`Team "${teamName}" configuration is no longer available`); 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.'); 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) { if (!config) {
throw new Error(`Team "${teamName}" configuration is no longer available`); 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). // as read after native delivery, so we must scan ALL messages (including read).
let config: Awaited<ReturnType<TeamConfigReader['getConfig']>> | null = null; let config: Awaited<ReturnType<TeamConfigReader['getConfig']>> | null = null;
try { try {
config = await this.configReader.getConfig(teamName); config = await this.readConfigForObservation(teamName);
} catch { } catch {
// config not ready yet during early provisioning — skip scan // 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) // Re-read config if needed (already fetched above but guard provisioningComplete path)
if (!config) { if (!config) {
try { try {
config = await this.configReader.getConfig(teamName); config = await this.readConfigForObservation(teamName);
} catch { } catch {
return 0; return 0;
} }
@ -16782,7 +16826,7 @@ export class TeamProvisioningService {
for (const teamName of aliveTeams) { for (const teamName of aliveTeams) {
try { try {
const config = await this.configReader.getConfig(teamName); const config = await this.readConfigForStrictDecision(teamName);
if (!config) continue; if (!config) continue;
const oldCode = config.language || 'system'; const oldCode = config.language || 'system';
@ -21962,7 +22006,7 @@ export class TeamProvisioningService {
let currentMembers: TeamCreateRequest['members'] = run.request.members; let currentMembers: TeamCreateRequest['members'] = run.request.members;
let leadName = 'team-lead'; let leadName = 'team-lead';
try { try {
const config = await this.configReader.getConfig(run.teamName); const config = await this.readConfigForObservation(run.teamName);
if (config?.members) { if (config?.members) {
const configLead = config.members.find((m) => isLeadMember(m)); const configLead = config.members.find((m) => isLeadMember(m));
leadName = configLead?.name?.trim() || 'team-lead'; leadName = configLead?.name?.trim() || 'team-lead';
@ -22122,7 +22166,7 @@ export class TeamProvisioningService {
let leadName = let leadName =
run.effectiveMembers.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; run.effectiveMembers.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
try { try {
const config = await this.configReader.getConfig(run.teamName); const config = await this.readConfigForObservation(run.teamName);
if (config?.members) { if (config?.members) {
const configLead = config.members.find((m) => isLeadMember(m)); const configLead = config.members.find((m) => isLeadMember(m));
leadName = configLead?.name?.trim() || leadName; leadName = configLead?.name?.trim() || leadName;
@ -22833,7 +22877,7 @@ export class TeamProvisioningService {
// Resolve project cwd from team config // Resolve project cwd from team config
let projectCwd: string | undefined; let projectCwd: string | undefined;
try { try {
const config = await this.configReader.getConfig(run.teamName); const config = await this.readConfigForStrictDecision(run.teamName);
projectCwd = config?.projectPath ?? config?.members?.[0]?.cwd; projectCwd = config?.projectPath ?? config?.members?.[0]?.cwd;
} catch { } catch {
// best-effort // best-effort

View file

@ -46,6 +46,11 @@ interface SessionProjectMatch extends ProjectDirCandidate {
matchedSessionId: string; matchedSessionId: string;
} }
interface TeamTranscriptProjectConfigReader {
getConfig(teamName: string): Promise<TeamConfig | null>;
getConfigSnapshot?: (teamName: string) => Promise<TeamConfig | null>;
}
type ScannedSessionProjectMatch = Omit<SessionProjectMatch, 'projectPath'> & { type ScannedSessionProjectMatch = Omit<SessionProjectMatch, 'projectPath'> & {
projectPath?: string; projectPath?: string;
}; };
@ -187,9 +192,15 @@ export class TeamTranscriptProjectResolver {
>(); >();
constructor( constructor(
private readonly configReader: Pick<TeamConfigReader, 'getConfig'> = new TeamConfigReader() private readonly configReader: TeamTranscriptProjectConfigReader = new TeamConfigReader()
) {} ) {}
private readConfigForObservation(teamName: string): Promise<TeamConfig | null> {
return typeof this.configReader.getConfigSnapshot === 'function'
? this.configReader.getConfigSnapshot(teamName)
: this.configReader.getConfig(teamName);
}
async getContext( async getContext(
teamName: string, teamName: string,
options?: { forceRefresh?: boolean } options?: { forceRefresh?: boolean }
@ -203,7 +214,7 @@ export class TeamTranscriptProjectResolver {
return cached.value; return cached.value;
} }
const config = await this.configReader.getConfig(teamName); const config = await this.readConfigForObservation(teamName);
if (!config) { if (!config) {
return null; return null;
} }

View file

@ -1669,6 +1669,12 @@ export class BoardTaskLogStreamService {
private readonly historicalBoardMcpRawProbe: HistoricalBoardMcpRawProbe = new HistoricalBoardMcpRawProbe() private readonly historicalBoardMcpRawProbe: HistoricalBoardMcpRawProbe = new HistoricalBoardMcpRawProbe()
) {} ) {}
private readConfigForObservation(teamName: string) {
return typeof this.configReader.getConfigSnapshot === 'function'
? this.configReader.getConfigSnapshot(teamName)
: this.configReader.getConfig(teamName);
}
private buildLayoutCacheKey(teamName: string, taskId: string): string { private buildLayoutCacheKey(teamName: string, taskId: string): string {
return `${teamName}::${taskId}`; return `${teamName}::${taskId}`;
} }
@ -2199,7 +2205,7 @@ export class BoardTaskLogStreamService {
this.taskReader.getTasks(teamName).catch(() => []), this.taskReader.getTasks(teamName).catch(() => []),
this.taskReader.getDeletedTasks(teamName).catch(() => []), this.taskReader.getDeletedTasks(teamName).catch(() => []),
this.membersMetaStore.getMembers(teamName).catch(() => []), this.membersMetaStore.getMembers(teamName).catch(() => []),
this.configReader.getConfig(teamName).catch(() => null), this.readConfigForObservation(teamName).catch(() => null),
]); ]);
const task = [...activeTasks, ...deletedTasks].find((candidate) => candidate.id === taskId); const task = [...activeTasks, ...deletedTasks].find((candidate) => candidate.id === taskId);
const ownerName = task?.owner?.trim(); const ownerName = task?.owner?.trim();

View file

@ -54,6 +54,12 @@ export class CodexNativeTaskLogStreamSource {
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder() private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder()
) {} ) {}
private readConfigForObservation(teamName: string) {
return typeof this.configReader.getConfigSnapshot === 'function'
? this.configReader.getConfigSnapshot(teamName)
: this.configReader.getConfig(teamName);
}
async getTaskLogStream( async getTaskLogStream(
teamName: string, teamName: string,
taskId: string, taskId: string,
@ -163,7 +169,7 @@ export class CodexNativeTaskLogStreamSource {
const normalizedOwner = normalizeMemberName(ownerName); const normalizedOwner = normalizeMemberName(ownerName);
const [metaMembers, config] = await Promise.all([ const [metaMembers, config] = await Promise.all([
this.membersMetaStore.getMembers(teamName).catch(() => []), this.membersMetaStore.getMembers(teamName).catch(() => []),
this.configReader.getConfig(teamName).catch(() => null), this.readConfigForObservation(teamName).catch(() => null),
]); ]);
const member = [...metaMembers, ...(config?.members ?? [])].find( const member = [...metaMembers, ...(config?.members ?? [])].find(
(candidate) => normalizeMemberName(candidate.name) === normalizedOwner (candidate) => normalizeMemberName(candidate.name) === normalizedOwner

View file

@ -339,6 +339,35 @@ describe('TeamDataService task projection cache invalidation', () => {
expect(configInvalidateSpy).toHaveBeenCalledWith('gone-team'); expect(configInvalidateSpy).toHaveBeenCalledWith('gone-team');
expect(taskInvalidateSpy).toHaveBeenCalledTimes(1); expect(taskInvalidateSpy).toHaveBeenCalledTimes(1);
}); });
it('keeps team deletion mutations on verified config reads', async () => {
const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-data-delete-verified-'));
tempPaths.push(claudeRoot);
setClaudeBasePathOverride(claudeRoot);
await fs.mkdir(path.join(claudeRoot, 'teams', 'my-team'), { recursive: true });
const getConfig = vi.fn(async () => ({
name: 'My team',
members: [],
}));
const getConfigSnapshot = vi.fn(async () => {
throw new Error('snapshot config read should not be used for team deletion');
});
const service = new TeamDataService({
listTeams: vi.fn(),
getConfig,
getConfigSnapshot,
} as never);
await service.deleteTeam('my-team');
const written = JSON.parse(
await fs.readFile(path.join(claudeRoot, 'teams', 'my-team', 'config.json'), 'utf8')
) as TeamConfig;
expect(written.deletedAt).toBeTruthy();
expect(getConfig).toHaveBeenCalledWith('my-team');
expect(getConfigSnapshot).not.toHaveBeenCalled();
});
}); });
describe('TeamDataService draft metadata', () => { describe('TeamDataService draft metadata', () => {
@ -1370,15 +1399,20 @@ describe('TeamDataService', () => {
it('includes projectPath from config when creating a task', async () => { it('includes projectPath from config when creating a task', async () => {
const createTaskMock = vi.fn((task) => task); const createTaskMock = vi.fn((task) => task);
const getConfig = vi.fn(async () => {
throw new Error('verified config read should not be used for task enrichment');
});
const getConfigSnapshot = vi.fn(async () => ({
name: 'My team',
members: [],
projectPath: '/Users/dev/my-project',
}));
const service = new TeamDataService( const service = new TeamDataService(
{ {
listTeams: vi.fn(), listTeams: vi.fn(),
getConfig: vi.fn(async () => ({ getConfig,
name: 'My team', getConfigSnapshot,
members: [],
projectPath: '/Users/dev/my-project',
})),
} as never, } as never,
{ {
getNextTaskId: vi.fn(async () => '1'), getNextTaskId: vi.fn(async () => '1'),
@ -1417,6 +1451,8 @@ describe('TeamDataService', () => {
expect(createTaskMock).toHaveBeenCalledWith( expect(createTaskMock).toHaveBeenCalledWith(
expect.objectContaining({ projectPath: '/Users/dev/my-project' }) expect.objectContaining({ projectPath: '/Users/dev/my-project' })
); );
expect(getConfigSnapshot).toHaveBeenCalledWith('my-team');
expect(getConfig).not.toHaveBeenCalled();
}); });
it('returns lightweight notification context from config without hydrating team data', async () => { it('returns lightweight notification context from config without hydrating team data', async () => {

View file

@ -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<string, unknown>) => ({
ok: true,
providerId: 'opencode',
memberName: String(input.memberName),
sessionId: 'oc-session-bob',
diagnostics: [],
}));
svc.setRuntimeAdapterRegistry(
new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
sendMessageToMember,
} as any,
])
);
(svc as any).teamMetaStore = {
getMeta: vi.fn(async () => ({
launchIdentity: { providerId: 'codex' },
providerId: 'codex',
})),
};
(svc as any).membersMetaStore = {
getMembers: vi.fn(async () => [
{ name: 'bob', providerId: 'opencode', model: 'opencode/minimax-m2.5-free' },
]),
};
(svc as any).setSecondaryRuntimeRun({
teamName: 'team-a',
runId: 'opencode-run-bob',
providerId: 'opencode',
laneId: 'secondary:opencode:bob',
memberName: 'bob',
cwd: '/repo',
});
await expect(
svc.deliverOpenCodeMemberMessage('team-a', {
memberName: 'bob',
text: 'hello bob',
messageId: 'msg-1',
})
).resolves.toMatchObject({ delivered: true });
expect(getConfigSnapshot).toHaveBeenCalledWith('team-a');
expect(getConfig).not.toHaveBeenCalled();
});
it('resolves OpenCode runtime lane members from one snapshot directory read', async () => {
const getConfig = vi.fn(async () => {
throw new Error('verified config read should not be used for lane member resolution');
});
const getConfigSnapshot = vi.fn(async () => ({
projectPath: '/repo',
members: [
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
{ name: 'alice', providerId: 'codex', model: 'gpt-5.4' },
],
}));
const svc = new TeamProvisioningService({
getConfig,
getConfigSnapshot,
} as any);
(svc as any).teamMetaStore = {
getMeta: vi.fn(async () => ({
launchIdentity: { providerId: 'codex' },
providerId: 'codex',
})),
};
(svc as any).membersMetaStore = {
getMembers: vi.fn(async () => [
{ name: 'bob', providerId: 'opencode', model: 'opencode/minimax-m2.5-free' },
]),
};
await expect(
(svc as any).resolveOpenCodeMembersForRuntimeLane(
'team-a',
'secondary:opencode:bob'
)
).resolves.toEqual(['bob']);
expect(getConfigSnapshot).toHaveBeenCalledTimes(1);
expect(getConfig).not.toHaveBeenCalled();
});
it('delivers OpenCode secondary-lane messages to the member worktree cwd after restart', async () => { it('delivers OpenCode secondary-lane messages to the member worktree cwd after restart', async () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({ const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
@ -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 () => { it('rejects duplicate OpenCode bootstrap check-ins for members removed after the first check-in', async () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
const previousSnapshot = { const previousSnapshot = {
@ -9664,11 +9796,16 @@ describe('TeamProvisioningService', () => {
it('expands teammate permission suggestions to the operational tool set only', async () => { it('expands teammate permission suggestions to the operational tool set only', async () => {
allowConsoleLogs(); allowConsoleLogs();
const getConfig = vi.fn(async () => ({
projectPath: tempClaudeRoot,
members: [{ cwd: tempClaudeRoot }],
}));
const getConfigSnapshot = vi.fn(async () => {
throw new Error('snapshot config read should not be used for permission writes');
});
const svc = new TeamProvisioningService({ const svc = new TeamProvisioningService({
getConfig: vi.fn(async () => ({ getConfig,
projectPath: tempClaudeRoot, getConfigSnapshot,
members: [{ cwd: tempClaudeRoot }],
})),
} as any); } as any);
await (svc as any).respondToTeammatePermission( await (svc as any).respondToTeammatePermission(
@ -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__team_stop');
expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__kanban_clear'); expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__kanban_clear');
expect(getConfig).toHaveBeenCalledWith('ops-team');
expect(getConfigSnapshot).not.toHaveBeenCalled();
}); });
it('does not broaden admin/runtime teammate permission suggestions', async () => { it('does not broaden admin/runtime teammate permission suggestions', async () => {

View file

@ -307,6 +307,45 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1); expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1);
}); });
it('uses snapshot config reads for lead inbox relay routing', async () => {
const getConfig = vi.fn(async () => {
throw new Error('verified config read should not be used for inbox relay routing');
});
const getConfigSnapshot = vi.fn(async () => ({
name: 'My Team',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}));
const service = new TeamProvisioningService({
getConfig,
getConfigSnapshot,
} as any);
const teamName = 'my-team';
seedLeadInbox(teamName, [
{
from: 'bob',
text: 'Please assign this to Alice.',
timestamp: '2026-02-23T10:00:00.000Z',
read: false,
summary: 'Need delegation',
messageId: 'm-1',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const relayPromise = service.relayLeadInboxMessages(teamName);
const run = await waitForCapture(service);
(service as any).handleStreamJsonMessage(run, {
type: 'assistant',
content: [{ type: 'text', text: 'OK, will do.' }],
});
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
await expect(relayPromise).resolves.toBe(1);
expect(writeSpy).toHaveBeenCalledTimes(1);
expect(getConfigSnapshot).toHaveBeenCalledWith(teamName);
expect(getConfig).not.toHaveBeenCalled();
});
it('shows assistant text after relay capture has already settled', () => { it('shows assistant text after relay capture has already settled', () => {
const service = new TeamProvisioningService(); const service = new TeamProvisioningService();
const teamName = 'my-team'; const teamName = 'my-team';

View file

@ -140,6 +140,30 @@ describe('TeamTranscriptProjectResolver', () => {
return { projectDir, jsonlPath }; return { projectDir, jsonlPath };
} }
it('uses snapshot-capable config readers for resolver observations', async () => {
await setupClaudeRoot();
const { projectDir } = await createSessionFile('/repo/current', 'lead-session-1');
const getConfig = vi.fn(async () => {
throw new Error('verified config read should not be used for transcript observations');
});
const getConfigSnapshot = vi.fn(async () => ({
name: 'My Team',
projectPath: '/repo/current',
leadSessionId: 'lead-session-1',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
}));
const resolver = new TeamTranscriptProjectResolver({
getConfig,
getConfigSnapshot,
});
const context = await resolver.getContext('my-team');
expect(context?.projectDir).toBe(projectDir);
expect(getConfigSnapshot).toHaveBeenCalledWith('my-team');
expect(getConfig).not.toHaveBeenCalled();
});
it('repairs stale projectPath when exact leadSessionId exists only in the renamed project', async () => { it('repairs stale projectPath when exact leadSessionId exists only in the renamed project', async () => {
await setupClaudeRoot(); await setupClaudeRoot();