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;
|
kanbanManager: TeamKanbanManager;
|
||||||
membersMetaStore: TeamMembersMetaStore;
|
membersMetaStore: TeamMembersMetaStore;
|
||||||
isTeamActive?: (teamName: string) => Promise<boolean> | boolean;
|
isTeamActive?: (teamName: string) => Promise<boolean> | boolean;
|
||||||
|
canDispatchNudges?: (teamName: string) => Promise<boolean> | boolean;
|
||||||
listLifecycleActiveTeamNames?: () => Promise<string[]>;
|
listLifecycleActiveTeamNames?: () => Promise<string[]>;
|
||||||
queueQuietWindowMs?: number;
|
queueQuietWindowMs?: number;
|
||||||
runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort;
|
runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort;
|
||||||
|
|
@ -208,13 +209,51 @@ export function createMemberWorkSyncFeature(deps: {
|
||||||
const reconciler = new MemberWorkSyncReconciler(useCaseDeps);
|
const reconciler = new MemberWorkSyncReconciler(useCaseDeps);
|
||||||
const pendingReportReplayer = new MemberWorkSyncPendingReportIntentReplayer(useCaseDeps);
|
const pendingReportReplayer = new MemberWorkSyncPendingReportIntentReplayer(useCaseDeps);
|
||||||
const nudgeDispatcher = new MemberWorkSyncNudgeDispatcher(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({
|
const queue = new MemberWorkSyncEventQueue({
|
||||||
reconcile: async (request, context: MemberWorkSyncReconcileContext) => {
|
reconcile: async (request, context: MemberWorkSyncReconcileContext) => {
|
||||||
await reconciler.execute(request, context);
|
await reconciler.execute(request, context);
|
||||||
await nudgeDispatcher.dispatchDue({
|
await dispatchNudgesForReadyTeams([request.teamName], `member-work-sync:${process.pid}`);
|
||||||
teamNames: [request.teamName],
|
|
||||||
claimedBy: `member-work-sync:${process.pid}`,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
isTeamActive: deps.isTeamActive ?? (() => true),
|
isTeamActive: deps.isTeamActive ?? (() => true),
|
||||||
...(deps.queueQuietWindowMs != null ? { quietWindowMs: deps.queueQuietWindowMs } : {}),
|
...(deps.queueQuietWindowMs != null ? { quietWindowMs: deps.queueQuietWindowMs } : {}),
|
||||||
|
|
@ -264,10 +303,7 @@ export function createMemberWorkSyncFeature(deps: {
|
||||||
? new MemberWorkSyncNudgeDispatchScheduler({
|
? new MemberWorkSyncNudgeDispatchScheduler({
|
||||||
listLifecycleActiveTeamNames: deps.listLifecycleActiveTeamNames,
|
listLifecycleActiveTeamNames: deps.listLifecycleActiveTeamNames,
|
||||||
dispatchDue: (teamNames) =>
|
dispatchDue: (teamNames) =>
|
||||||
nudgeDispatcher.dispatchDue({
|
dispatchNudgesForReadyTeams(teamNames, `member-work-sync:${process.pid}:scheduled`),
|
||||||
teamNames,
|
|
||||||
claimedBy: `member-work-sync:${process.pid}:scheduled`,
|
|
||||||
}),
|
|
||||||
logger: deps.logger,
|
logger: deps.logger,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -322,10 +358,7 @@ export function createMemberWorkSyncFeature(deps: {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
dispatchDueNudges: (teamNames) =>
|
dispatchDueNudges: (teamNames) =>
|
||||||
nudgeDispatcher.dispatchDue({
|
dispatchNudgesForReadyTeams(teamNames, `member-work-sync:${process.pid}`),
|
||||||
teamNames,
|
|
||||||
claimedBy: `member-work-sync:${process.pid}`,
|
|
||||||
}),
|
|
||||||
buildRuntimeTurnSettledHookSettings: async ({ provider }) =>
|
buildRuntimeTurnSettledHookSettings: async ({ provider }) =>
|
||||||
runtimeTurnSettledSpool.buildHookSettings({ provider }),
|
runtimeTurnSettledSpool.buildHookSettings({ provider }),
|
||||||
buildRuntimeTurnSettledEnvironment: async ({ provider }) =>
|
buildRuntimeTurnSettledEnvironment: async ({ provider }) =>
|
||||||
|
|
|
||||||
|
|
@ -1607,6 +1607,7 @@ async function initializeServices(): Promise<void> {
|
||||||
isTeamActive: (teamName) =>
|
isTeamActive: (teamName) =>
|
||||||
teamProvisioningService.isTeamAlive(teamName) ||
|
teamProvisioningService.isTeamAlive(teamName) ||
|
||||||
teamProvisioningService.hasProvisioningRun(teamName),
|
teamProvisioningService.hasProvisioningRun(teamName),
|
||||||
|
canDispatchNudges: (teamName) => teamProvisioningService.isTeamAlive(teamName),
|
||||||
listLifecycleActiveTeamNames: async () => {
|
listLifecycleActiveTeamNames: async () => {
|
||||||
const teams = await teamDataService.listTeams();
|
const teams = await teamDataService.listTeams();
|
||||||
return teams
|
return teams
|
||||||
|
|
|
||||||
|
|
@ -25458,7 +25458,7 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
return expectedMembers.every((memberName) => {
|
return expectedMembers.every((memberName) => {
|
||||||
const member = run.memberSpawnStatuses.get(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 displayName = run.request.displayName || run.teamName;
|
||||||
const joinedCount = run.expectedMembers?.length ?? 0;
|
const joinedCount = run.expectedMembers?.length ?? 0;
|
||||||
const allJoined = joinedCount > 0 && this.areAllExpectedLaunchMembersConfirmed(run);
|
const allJoined = joinedCount > 0 && this.areAllExpectedLaunchMembersConfirmed(run);
|
||||||
|
if (run.isLaunch && joinedCount > 0 && !allJoined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const body = run.isLaunch
|
const body = run.isLaunch
|
||||||
? allJoined
|
? allJoined
|
||||||
? `Team "${displayName}" has been launched - all ${joinedCount} teammates joined and are ready for tasks.`
|
? `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 () => {
|
it('plans and dispatches due nudges after queued reconcile by default', async () => {
|
||||||
const claudeRoot = makeTempRoot();
|
const claudeRoot = makeTempRoot();
|
||||||
setClaudeBasePathOverride(claudeRoot);
|
setClaudeBasePathOverride(claudeRoot);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue