From 6e67e9b3a483f8788d2d6c535c0df33ccbe626db Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 13 May 2026 18:09:05 +0300 Subject: [PATCH] fix(team): stabilize launch readiness signals --- .../createMemberWorkSyncFeature.ts | 57 ++++++++++--- src/main/index.ts | 1 + .../services/team/TeamProvisioningService.ts | 5 +- .../main/createMemberWorkSyncFeature.test.ts | 84 +++++++++++++++++++ 4 files changed, 134 insertions(+), 13 deletions(-) diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index 01b41483..6fae365e 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -125,6 +125,7 @@ export function createMemberWorkSyncFeature(deps: { kanbanManager: TeamKanbanManager; membersMetaStore: TeamMembersMetaStore; isTeamActive?: (teamName: string) => Promise | boolean; + canDispatchNudges?: (teamName: string) => Promise | boolean; listLifecycleActiveTeamNames?: () => Promise; 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 => { + 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 => { + 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 }) => diff --git a/src/main/index.ts b/src/main/index.ts index 643ea821..47b2e22d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1607,6 +1607,7 @@ async function initializeServices(): Promise { isTeamActive: (teamName) => teamProvisioningService.isTeamAlive(teamName) || teamProvisioningService.hasProvisioningRun(teamName), + canDispatchNudges: (teamName) => teamProvisioningService.isTeamAlive(teamName), listLifecycleActiveTeamNames: async () => { const teams = await teamDataService.listTeams(); return teams diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index a7cd4f70..4fb9dc77 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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.` diff --git a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts index a03e04c9..e926db85 100644 --- a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts +++ b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts @@ -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);