fix(team): stabilize launch readiness signals
This commit is contained in:
parent
29ea1ae724
commit
6e67e9b3a4
4 changed files with 134 additions and 13 deletions
|
|
@ -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 }) =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.`
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue