fix(team): stabilize launch readiness signals

This commit is contained in:
777genius 2026-05-13 18:09:05 +03:00
parent 29ea1ae724
commit 6e67e9b3a4
4 changed files with 134 additions and 13 deletions

View file

@ -125,6 +125,7 @@ export function createMemberWorkSyncFeature(deps: {
kanbanManager: TeamKanbanManager;
membersMetaStore: TeamMembersMetaStore;
isTeamActive?: (teamName: string) => Promise<boolean> | boolean;
canDispatchNudges?: (teamName: string) => Promise<boolean> | boolean;
listLifecycleActiveTeamNames?: () => Promise<string[]>;
queueQuietWindowMs?: number;
runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort;
@ -208,13 +209,51 @@ export function createMemberWorkSyncFeature(deps: {
const reconciler = new MemberWorkSyncReconciler(useCaseDeps);
const pendingReportReplayer = new MemberWorkSyncPendingReportIntentReplayer(useCaseDeps);
const nudgeDispatcher = new MemberWorkSyncNudgeDispatcher(useCaseDeps);
const emptyNudgeDispatchSummary = (): MemberWorkSyncNudgeDispatchSummary => ({
claimed: 0,
delivered: 0,
superseded: 0,
retryable: 0,
terminal: 0,
});
const filterNudgeDispatchReadyTeamNames = async (teamNames: string[]): Promise<string[]> => {
const uniqueTeamNames = [...new Set(teamNames.map((name) => name.trim()).filter(Boolean))];
if (!deps.canDispatchNudges) {
return uniqueTeamNames;
}
const readiness = await Promise.all(
uniqueTeamNames.map(async (teamName) => {
try {
return { teamName, ready: await deps.canDispatchNudges!(teamName) };
} catch (error) {
deps.logger?.warn('member work sync nudge dispatch readiness check failed', {
teamName,
error: String(error),
});
return { teamName, ready: false };
}
})
);
return readiness.filter((item) => item.ready).map((item) => item.teamName);
};
const dispatchNudgesForReadyTeams = async (
teamNames: string[],
claimedBy: string
): Promise<MemberWorkSyncNudgeDispatchSummary> => {
const readyTeamNames = await filterNudgeDispatchReadyTeamNames(teamNames);
if (readyTeamNames.length === 0) {
return emptyNudgeDispatchSummary();
}
return nudgeDispatcher.dispatchDue({
teamNames: readyTeamNames,
claimedBy,
});
};
const queue = new MemberWorkSyncEventQueue({
reconcile: async (request, context: MemberWorkSyncReconcileContext) => {
await reconciler.execute(request, context);
await nudgeDispatcher.dispatchDue({
teamNames: [request.teamName],
claimedBy: `member-work-sync:${process.pid}`,
});
await dispatchNudgesForReadyTeams([request.teamName], `member-work-sync:${process.pid}`);
},
isTeamActive: deps.isTeamActive ?? (() => true),
...(deps.queueQuietWindowMs != null ? { quietWindowMs: deps.queueQuietWindowMs } : {}),
@ -264,10 +303,7 @@ export function createMemberWorkSyncFeature(deps: {
? new MemberWorkSyncNudgeDispatchScheduler({
listLifecycleActiveTeamNames: deps.listLifecycleActiveTeamNames,
dispatchDue: (teamNames) =>
nudgeDispatcher.dispatchDue({
teamNames,
claimedBy: `member-work-sync:${process.pid}:scheduled`,
}),
dispatchNudgesForReadyTeams(teamNames, `member-work-sync:${process.pid}:scheduled`),
logger: deps.logger,
})
: null;
@ -322,10 +358,7 @@ export function createMemberWorkSyncFeature(deps: {
);
},
dispatchDueNudges: (teamNames) =>
nudgeDispatcher.dispatchDue({
teamNames,
claimedBy: `member-work-sync:${process.pid}`,
}),
dispatchNudgesForReadyTeams(teamNames, `member-work-sync:${process.pid}`),
buildRuntimeTurnSettledHookSettings: async ({ provider }) =>
runtimeTurnSettledSpool.buildHookSettings({ provider }),
buildRuntimeTurnSettledEnvironment: async ({ provider }) =>

View file

@ -1607,6 +1607,7 @@ async function initializeServices(): Promise<void> {
isTeamActive: (teamName) =>
teamProvisioningService.isTeamAlive(teamName) ||
teamProvisioningService.hasProvisioningRun(teamName),
canDispatchNudges: (teamName) => teamProvisioningService.isTeamAlive(teamName),
listLifecycleActiveTeamNames: async () => {
const teams = await teamDataService.listTeams();
return teams

View file

@ -25458,7 +25458,7 @@ export class TeamProvisioningService {
return expectedMembers.every((memberName) => {
const member = run.memberSpawnStatuses.get(memberName);
return member?.launchState === 'confirmed_alive' || member?.bootstrapConfirmed === true;
return member?.launchState === 'confirmed_alive';
});
}
@ -31174,6 +31174,9 @@ export class TeamProvisioningService {
const displayName = run.request.displayName || run.teamName;
const joinedCount = run.expectedMembers?.length ?? 0;
const allJoined = joinedCount > 0 && this.areAllExpectedLaunchMembersConfirmed(run);
if (run.isLaunch && joinedCount > 0 && !allJoined) {
return;
}
const body = run.isLaunch
? allJoined
? `Team "${displayName}" has been launched - all ${joinedCount} teammates joined and are ready for tasks.`

View file

@ -388,6 +388,90 @@ describe('createMemberWorkSyncFeature composition', () => {
}
});
it('does not deliver pending nudges until the team is ready for nudge dispatch', async () => {
const claudeRoot = makeTempRoot();
setClaudeBasePathOverride(claudeRoot);
const teamsBasePath = getTeamsBasePath();
const teamName = 'team-a';
const memberName = 'bob';
let canDispatchNudges = false;
const feature = createMemberWorkSyncFeature({
teamsBasePath,
configReader: {
getConfig: vi.fn(async () => ({
name: teamName,
members: [{ name: memberName }],
})),
} as never,
taskReader: {
getTasks: vi.fn(async () => [
{
id: 'task-1',
displayId: '11111111',
subject: 'Ship sync',
status: 'pending',
owner: memberName,
},
]),
} as never,
kanbanManager: {
getState: vi.fn(async () => ({
teamName,
reviewers: [],
tasks: {},
})),
} as never,
membersMetaStore: {
getMembers: vi.fn(async () => []),
} as never,
canDispatchNudges: vi.fn(async () => canDispatchNudges),
});
try {
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
const status = await feature.refreshStatus({ teamName, memberName });
const outboxInput = buildMemberWorkSyncOutboxEnsureInput({
status,
hash: new NodeHashAdapter(),
nowIso: status.evaluatedAt,
});
expect(outboxInput).not.toBeNull();
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath));
await expect(store.ensurePending(outboxInput!)).resolves.toMatchObject({
ok: true,
outcome: 'created',
});
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
claimed: 0,
delivered: 0,
superseded: 0,
retryable: 0,
terminal: 0,
});
await expect(readInboxMessages({ teamsBasePath, teamName, memberName })).resolves.toEqual([]);
await expect(
readMemberOutboxItems({ teamsBasePath, teamName, memberName })
).resolves.toMatchObject({
[outboxInput!.id]: { status: 'pending' },
});
canDispatchNudges = true;
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
claimed: 1,
delivered: 1,
superseded: 0,
retryable: 0,
terminal: 0,
});
await expect(
readInboxMessages({ teamsBasePath, teamName, memberName })
).resolves.toMatchObject([{ messageId: outboxInput!.id }]);
} finally {
await feature.dispose();
}
});
it('plans and dispatches due nudges after queued reconcile by default', async () => {
const claudeRoot = makeTempRoot();
setClaudeBasePathOverride(claudeRoot);