Skip to content

Commit cc07c6f

Browse files
committed
fix(team): keep removed roster authoritative on relaunch
1 parent ae35ade commit cc07c6f

13 files changed

Lines changed: 265 additions & 79 deletions

src/main/services/team/provisioning/TeamProvisioningConfigFacade.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import type {
3838
TeamConfig,
3939
TeamCreateRequest,
4040
TeamLaunchRequest,
41+
TeamMember,
4142
TeamProviderId,
4243
} from '@shared/types';
4344

@@ -50,10 +51,15 @@ export interface TeamProvisioningConfigFacadeInboxReader {
5051
listInboxNames(teamName: string): Promise<string[]>;
5152
}
5253

54+
export interface TeamProvisioningConfigFacadeMembersMetaStore
55+
extends TeamProvisioningConfigMaintenanceMembersMetaStore {
56+
getMeta(teamName: string): Promise<{ members: TeamMember[] } | null>;
57+
}
58+
5359
export interface TeamProvisioningConfigFacadeOptions {
5460
configReader: TeamProvisioningConfigFacadeReader;
5561
inboxReader: TeamProvisioningConfigFacadeInboxReader;
56-
membersMetaStore: TeamProvisioningConfigMaintenanceMembersMetaStore;
62+
membersMetaStore: TeamProvisioningConfigFacadeMembersMetaStore;
5763
launchStateStore: {
5864
read(teamName: string): Promise<unknown>;
5965
};

src/main/services/team/provisioning/TeamProvisioningLaunchExpectedMembers.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export interface ResolveLaunchExpectedMembersInput {
2222
export interface TeamProvisioningLaunchExpectedMembersPorts {
2323
readLaunchState(teamName: string): Promise<unknown>;
2424
readBootstrapLaunchSnapshot(teamName: string): Promise<unknown>;
25-
getMembers(teamName: string): Promise<TeamMember[]>;
25+
getMeta(teamName: string): Promise<{ members: TeamMember[] } | null>;
2626
listInboxNames(teamName: string): Promise<string[]>;
2727
warn(message: string): void;
2828
}
@@ -54,13 +54,12 @@ export async function probeLaunchCompatibility(
5454
]);
5555

5656
try {
57-
const metaMembers = await ports.getMembers(teamName);
58-
const members = buildLaunchMembersFromMeta(metaMembers);
59-
if (members.length > 0) {
57+
const meta = await ports.getMeta(teamName);
58+
if (meta) {
6059
return {
6160
level: 'ready',
6261
rosterSource: 'members-meta',
63-
members,
62+
members: buildLaunchMembersFromMeta(meta.members),
6463
warnings: [],
6564
blockers: [],
6665
};

src/main/services/team/provisioning/TeamProvisioningLaunchExpectedMembersPortsFactory.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export interface TeamProvisioningLaunchExpectedMembersPortsFactoryDeps {
77
};
88
readBootstrapLaunchSnapshot(teamName: string): Promise<unknown>;
99
membersMetaStore: {
10-
getMembers(teamName: string): Promise<TeamMember[]>;
10+
getMeta(teamName: string): Promise<{ members: TeamMember[] } | null>;
1111
};
1212
inboxReader: {
1313
listInboxNames(teamName: string): Promise<string[]>;
@@ -23,7 +23,7 @@ export function createTeamProvisioningLaunchExpectedMembersPorts(
2323
return {
2424
readLaunchState: (teamName) => deps.launchStateStore.read(teamName),
2525
readBootstrapLaunchSnapshot: (teamName) => deps.readBootstrapLaunchSnapshot(teamName),
26-
getMembers: (teamName) => deps.membersMetaStore.getMembers(teamName),
26+
getMeta: (teamName) => deps.membersMetaStore.getMeta(teamName),
2727
listInboxNames: (teamName) => deps.inboxReader.listInboxNames(teamName),
2828
warn: (message) => deps.logger.warn(message),
2929
};

src/main/services/team/provisioning/TeamProvisioningServiceMemberLifecycleFacade.ts

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import {
1515
createNodeAppendDirectProcessRuntimeEventUseCasePorts,
1616
} from './TeamProvisioningAppendDirectProcessRuntimeEventUseCase';
1717
import { type TeamProvisioningCompatibilityDelegation } from './TeamProvisioningCompatibilityFacade';
18-
import { buildLaunchMembersFromMeta } from './TeamProvisioningConfigMaterialization';
1918
import { type TeamProvisioningCreateDeterministicSpawnFlowBoundary } from './TeamProvisioningCreateDeterministicSpawnFlowPortsFactory';
2019
import { type ProvisioningEnvResolution } from './TeamProvisioningEnvBuilder';
2120
import { type TeamProvisioningIdlePromptInjectionBoundary } from './TeamProvisioningIdlePromptInjectionPortsFactory';
@@ -147,27 +146,6 @@ function preserveProvisioningRemovalTombstones(store: TeamMembersMetaStore): Tea
147146
});
148147
}
149148

150-
function preserveAuthoritativeMembersMetaResolution(
151-
facade: TeamProvisioningServiceComposition['configFacade'],
152-
store: TeamMembersMetaStore
153-
): void {
154-
const fallback = facade.resolveLaunchExpectedMembers.bind(facade);
155-
facade.resolveLaunchExpectedMembers = async (teamName, configRaw, leadProviderId) => {
156-
try {
157-
const meta = await store.getMeta(teamName);
158-
if (meta) {
159-
return {
160-
members: buildLaunchMembersFromMeta(meta.members),
161-
source: 'members-meta',
162-
};
163-
}
164-
} catch {
165-
// The extracted resolver owns warning and fallback behavior for unreadable metadata.
166-
}
167-
return fallback(teamName, configRaw, leadProviderId);
168-
};
169-
}
170-
171149
/** Owns lifecycle host construction and launch-preparation adaptation. */
172150
export abstract class TeamProvisioningServiceMemberLifecycleFacade extends TeamProvisioningServiceRuntimeStateFacade {
173151
async runLiveRosterMutation(teamName: string, mutation: () => Promise<void>): Promise<void> {
@@ -392,7 +370,6 @@ export abstract class TeamProvisioningServiceMemberLifecycleFacade extends TeamP
392370
const membersMetaStore = preserveProvisioningRemovalTombstones(service.membersMetaStore);
393371
service.membersMetaStore = membersMetaStore;
394372
createTeamProvisioningServiceComposition(this);
395-
preserveAuthoritativeMembersMetaResolution(this.configFacade, membersMetaStore);
396373
this.preserveAtomicOpenCodeRuntimePreparation();
397374
this.staleAnthropicApiKeyHelperCleanupRetryOwner.start();
398375
}

src/main/services/team/provisioning/__tests__/TeamProvisioningConfigFacade.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ describe('TeamProvisioningConfigFacade', () => {
3030
const writeMembers = vi.fn(async () => undefined);
3131
const { facade } = createFacade({
3232
membersMetaStore: {
33+
getMeta: vi.fn(async () => null),
3334
getMembers: vi.fn(async () => []),
3435
writeMembers,
3536
},
@@ -72,6 +73,7 @@ function createFacade(overrides: Partial<TeamProvisioningConfigFacadeOptions> =
7273
listInboxNames: vi.fn(async () => []),
7374
},
7475
membersMetaStore: {
76+
getMeta: vi.fn(async () => null),
7577
getMembers: vi.fn(async () => []),
7678
writeMembers: vi.fn(async (_teamName: string, _members: TeamMember[]) => undefined),
7779
},

src/main/services/team/provisioning/__tests__/TeamProvisioningLaunchDeterministicSetupFlow.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ function createPorts(
101101
launchExpectedMembersPorts: {
102102
readLaunchState: vi.fn(async () => null),
103103
readBootstrapLaunchSnapshot: vi.fn(async () => null),
104-
getMembers: vi.fn(async () => members),
104+
getMeta: vi.fn(async () => ({ members })),
105105
listInboxNames: vi.fn(async () => []),
106106
warn: vi.fn(),
107107
},
@@ -168,7 +168,7 @@ describe('TeamProvisioningLaunchDeterministicSetupFlow', () => {
168168
});
169169

170170
expect(ports.deleteProvisioningRunByTeam).toHaveBeenCalledWith('demo');
171-
expect(ports.launchExpectedMembersPorts.getMembers).not.toHaveBeenCalled();
171+
expect(ports.launchExpectedMembersPorts.getMeta).not.toHaveBeenCalled();
172172
expect(ports.normalizeTeamConfigForLaunch).not.toHaveBeenCalled();
173173
});
174174

src/main/services/team/provisioning/__tests__/TeamProvisioningLaunchExpectedMembers.test.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ function makePorts(
1313
return {
1414
readLaunchState: vi.fn<(teamName: string) => Promise<unknown>>(async () => null),
1515
readBootstrapLaunchSnapshot: vi.fn<(teamName: string) => Promise<unknown>>(async () => null),
16-
getMembers: vi.fn<(teamName: string) => Promise<TeamMember[]>>(async () => []),
16+
getMeta: vi.fn<(teamName: string) => Promise<{ members: TeamMember[] } | null>>(async () =>
17+
null
18+
),
1719
listInboxNames: vi.fn<(teamName: string) => Promise<string[]>>(async () => []),
1820
warn: vi.fn<(message: string) => void>(),
1921
...overrides,
@@ -33,10 +35,12 @@ describe('team provisioning launch expected members', () => {
3335
const ports = makePorts({
3436
readLaunchState,
3537
readBootstrapLaunchSnapshot,
36-
getMembers: vi.fn<(teamName: string) => Promise<TeamMember[]>>(async () => [
37-
{ name: 'team-lead', agentType: 'team-lead' },
38-
{ name: 'Alice', role: 'Engineer', model: 'claude-sonnet-4-20250514' },
39-
]),
38+
getMeta: vi.fn<(teamName: string) => Promise<{ members: TeamMember[] } | null>>(async () => ({
39+
members: [
40+
{ name: 'team-lead', agentType: 'team-lead' },
41+
{ name: 'Alice', role: 'Engineer', model: 'claude-sonnet-4-20250514' },
42+
],
43+
})),
4044
listInboxNames: vi.fn<(teamName: string) => Promise<string[]>>(async () => ['Bob']),
4145
});
4246

@@ -81,6 +85,30 @@ describe('team provisioning launch expected members', () => {
8185
});
8286
});
8387

88+
it.each<{ label: string; members: TeamMember[] }>([
89+
{ label: 'empty', members: [] },
90+
{
91+
label: 'tombstone-only',
92+
members: [{ name: 'Alice', role: 'Engineer', removedAt: 123 }],
93+
},
94+
])('treats valid $label members.meta as authoritative over stale inboxes', async ({ members }) => {
95+
const listInboxNames = vi.fn<(teamName: string) => Promise<string[]>>(async () => ['Alice']);
96+
97+
const result = await resolveLaunchExpectedMembers(
98+
{
99+
teamName: 'team-a',
100+
configRaw: JSON.stringify({ members: [{ name: 'Alice', role: 'Engineer' }] }),
101+
},
102+
makePorts({
103+
getMeta: vi.fn(async () => ({ members })),
104+
listInboxNames,
105+
})
106+
);
107+
108+
expect(result).toEqual({ source: 'members-meta', members: [] });
109+
expect(listInboxNames).not.toHaveBeenCalled();
110+
});
111+
84112
it('falls back to config members when members.meta and inboxes are empty', async () => {
85113
const result = await resolveLaunchExpectedMembers(
86114
{

src/main/services/team/provisioning/__tests__/TeamProvisioningLaunchExpectedMembersPortsFactory.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ describe('team provisioning launch expected members ports factory', () => {
1919
},
2020
readBootstrapLaunchSnapshot: vi.fn(async () => bootstrapSnapshot),
2121
membersMetaStore: {
22-
getMembers: vi.fn(async () => members),
22+
getMeta: vi.fn(async () => ({ members })),
2323
},
2424
inboxReader: {
2525
listInboxNames: vi.fn(async () => inboxNames),
@@ -30,13 +30,13 @@ describe('team provisioning launch expected members ports factory', () => {
3030

3131
await expect(ports.readLaunchState('alpha')).resolves.toBe(launchSnapshot);
3232
await expect(ports.readBootstrapLaunchSnapshot('alpha')).resolves.toBe(bootstrapSnapshot);
33-
await expect(ports.getMembers('alpha')).resolves.toBe(members);
33+
await expect(ports.getMeta('alpha')).resolves.toEqual({ members });
3434
await expect(ports.listInboxNames('alpha')).resolves.toBe(inboxNames);
3535
ports.warn('[alpha] warning');
3636

3737
expect(deps.launchStateStore.read).toHaveBeenCalledWith('alpha');
3838
expect(deps.readBootstrapLaunchSnapshot).toHaveBeenCalledWith('alpha');
39-
expect(deps.membersMetaStore.getMembers).toHaveBeenCalledWith('alpha');
39+
expect(deps.membersMetaStore.getMeta).toHaveBeenCalledWith('alpha');
4040
expect(deps.inboxReader.listInboxNames).toHaveBeenCalledWith('alpha');
4141
expect(deps.logger.warn).toHaveBeenCalledWith('[alpha] warning');
4242
});
@@ -51,7 +51,7 @@ function makeDeps(
5151
},
5252
readBootstrapLaunchSnapshot: vi.fn(async () => null),
5353
membersMetaStore: {
54-
getMembers: vi.fn(async () => []),
54+
getMeta: vi.fn(async () => null),
5555
},
5656
inboxReader: {
5757
listInboxNames: vi.fn(async () => []),

0 commit comments

Comments
 (0)