diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts index 49439b5c..24d5b21b 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts @@ -7,16 +7,24 @@ import { type MemberWorkSyncTargetedRecoveryReason, } from './MemberWorkSyncTargetedRecoveryPolicy'; -import type { MemberWorkSyncStatus, MemberWorkSyncTeamMetrics } from '../../contracts'; +import type { + MemberWorkSyncMetricEvent, + MemberWorkSyncStatus, + MemberWorkSyncTeamMetrics, +} from '../../contracts'; export type MemberWorkSyncNudgeActivationReason = | 'shadow_ready' | MemberWorkSyncTargetedRecoveryReason | 'review_pickup_required' + | 'native_stale_in_progress' | 'status_not_nudgeable' | 'blocking_metrics' | 'phase2_not_ready'; +const NATIVE_STALE_IN_PROGRESS_MIN_AGE_MS = 6 * 60_000; +const NATIVE_STALE_IN_PROGRESS_PROVIDERS = new Set(['anthropic', 'codex', 'gemini']); + export interface MemberWorkSyncNudgeActivationDecision { active: boolean; reason: MemberWorkSyncNudgeActivationReason; @@ -32,6 +40,129 @@ function hasBlockingMetrics(metrics: MemberWorkSyncTeamMetrics): boolean { return metrics.phase2Readiness.reasons.some((reason) => BLOCKING_PHASE2_REASONS.has(reason)); } +function normalizeMemberName(value: string): string { + return value.trim().toLowerCase(); +} + +function isLeadLikeMemberName(memberName: string): boolean { + const normalized = normalizeMemberName(memberName).replace(/[\s_]+/g, '-'); + return ( + normalized === 'lead' || + normalized === 'team-lead' || + normalized === 'teamlead' || + normalized === 'team-leader' + ); +} + +function parseTime(value: string | undefined): number | null { + if (!value) { + return null; + } + const time = Date.parse(value); + return Number.isFinite(time) ? time : null; +} + +function eventsForMember( + status: MemberWorkSyncStatus, + metrics: MemberWorkSyncTeamMetrics +): MemberWorkSyncMetricEvent[] { + const memberName = normalizeMemberName(status.memberName); + return metrics.recentEvents + .filter((event) => normalizeMemberName(event.memberName) === memberName) + .sort((left, right) => left.recordedAt.localeCompare(right.recordedAt)); +} + +function hasAcceptedReportForCurrentFingerprint( + status: MemberWorkSyncStatus, + metrics: MemberWorkSyncTeamMetrics +): boolean { + return eventsForMember(status, metrics).some( + (event) => + event.kind === 'report_accepted' && event.agendaFingerprint === status.agenda.fingerprint + ); +} + +function isDifferentFingerprintBoundary( + event: MemberWorkSyncMetricEvent, + currentFingerprint: string +): boolean { + if (event.agendaFingerprint !== currentFingerprint) { + return true; + } + return ( + event.kind === 'fingerprint_changed' && + event.previousFingerprint !== undefined && + event.previousFingerprint !== currentFingerprint + ); +} + +function getCurrentFingerprintStableSinceMs( + status: MemberWorkSyncStatus, + metrics: MemberWorkSyncTeamMetrics, + nowMs: number +): number | null { + const currentFingerprint = status.agenda.fingerprint; + const memberEvents = eventsForMember(status, metrics).filter((event) => { + const recordedAt = parseTime(event.recordedAt); + return recordedAt != null && recordedAt <= nowMs; + }); + let latestDifferentFingerprintMs = Number.NEGATIVE_INFINITY; + for (const event of memberEvents) { + const recordedAt = parseTime(event.recordedAt); + if (recordedAt != null && isDifferentFingerprintBoundary(event, currentFingerprint)) { + latestDifferentFingerprintMs = Math.max(latestDifferentFingerprintMs, recordedAt); + } + } + + const currentNeedsSyncEventTimes = memberEvents.flatMap((event) => { + const recordedAt = parseTime(event.recordedAt); + return event.kind === 'status_evaluated' && + event.state === 'needs_sync' && + event.agendaFingerprint === currentFingerprint && + recordedAt != null && + recordedAt >= latestDifferentFingerprintMs + ? [recordedAt] + : []; + }); + + return currentNeedsSyncEventTimes.length > 0 ? Math.min(...currentNeedsSyncEventTimes) : null; +} + +function isNativeStaleInProgressCandidate(input: { + status: MemberWorkSyncStatus; + metrics: MemberWorkSyncTeamMetrics; +}): boolean { + const { status, metrics } = input; + if ( + status.state !== 'needs_sync' || + status.shadow?.wouldNudge !== true || + !status.diagnostics.includes('no_current_report') || + !status.providerId || + !NATIVE_STALE_IN_PROGRESS_PROVIDERS.has(status.providerId) || + isLeadLikeMemberName(status.memberName) || + status.agenda.items.length !== 1 || + hasAcceptedReportForCurrentFingerprint(status, metrics) + ) { + return false; + } + + const [item] = status.agenda.items; + if ( + item.kind !== 'work' || + item.reason !== 'owned_in_progress_task' || + item.evidence.status !== 'in_progress' + ) { + return false; + } + + const nowMs = parseTime(metrics.generatedAt) ?? parseTime(status.evaluatedAt); + if (nowMs == null) { + return false; + } + const stableSinceMs = getCurrentFingerprintStableSinceMs(status, metrics, nowMs); + return stableSinceMs != null && nowMs - stableSinceMs >= NATIVE_STALE_IN_PROGRESS_MIN_AGE_MS; +} + function isReviewPickupRequiredCandidate(status: MemberWorkSyncStatus): boolean { return ( status.state === 'needs_sync' && @@ -61,6 +192,10 @@ export function decideMemberWorkSyncNudgeActivation(input: { return { active: true, reason: targetedRecovery.reason }; } + if (isNativeStaleInProgressCandidate(input)) { + return { active: true, reason: 'native_stale_in_progress' }; + } + if (hasBlockingMetrics(input.metrics)) { return { active: false, reason: 'blocking_metrics' }; } diff --git a/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts b/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts index 74883e6a..86e3cad2 100644 --- a/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts +++ b/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts @@ -86,6 +86,62 @@ function metrics(overrides: Partial = {}): MemberWork }; } +function nativeStaleInProgressStatus( + overrides: Partial = {} +): MemberWorkSyncStatus { + const base = status({ + providerId: 'codex', + diagnostics: ['no_current_report'], + agenda: { + ...status().agenda, + fingerprint: 'agenda:v1:native-stale', + items: [ + { + taskId: 'task-1', + displayId: '#1', + subject: 'Review landing', + kind: 'work', + assignee: 'alice', + priority: 'normal', + reason: 'owned_in_progress_task', + evidence: { + status: 'in_progress', + owner: 'alice', + }, + }, + ], + }, + }); + return { ...base, ...overrides }; +} + +function staleMetrics( + overrides: Partial = {} +): MemberWorkSyncTeamMetrics { + return metrics({ + generatedAt: '2026-05-06T00:06:00.000Z', + phase2Readiness: { + ...metrics().phase2Readiness, + state: 'blocked', + reasons: ['would_nudge_rate_high', 'fingerprint_churn_high'], + }, + recentEvents: [ + { + id: 'status-stale', + teamName: 'team-a', + memberName: 'alice', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + recordedAt: '2026-05-06T00:00:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + ], + ...overrides, + }); +} + describe('MemberWorkSyncNudgeActivationPolicy', () => { it('activates OpenCode targeted nudges while shadow data is still collecting', () => { expect( @@ -348,6 +404,313 @@ describe('MemberWorkSyncNudgeActivationPolicy', () => { ).toEqual({ active: false, reason: 'blocking_metrics' }); }); + it('activates stale native single in-progress recovery despite blocking metrics', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus(), + metrics: staleMetrics(), + }) + ).toEqual({ active: true, reason: 'native_stale_in_progress' }); + }); + + it('does not activate stale native in-progress recovery before the quiet window elapses', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus(), + metrics: staleMetrics({ + generatedAt: '2026-05-06T00:05:59.000Z', + }), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + }); + + it('does not activate stale native in-progress recovery after an accepted report for the fingerprint', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus(), + metrics: staleMetrics({ + recentEvents: [ + ...staleMetrics().recentEvents, + { + id: 'report-accepted', + teamName: 'team-a', + memberName: 'alice', + kind: 'report_accepted', + state: 'still_working', + agendaFingerprint: 'agenda:v1:native-stale', + recordedAt: '2026-05-06T00:03:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + ], + }), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + }); + + it('does not activate stale native in-progress recovery when the accepted report state is still current', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus({ + state: 'still_working', + report: { + state: 'still_working', + agendaFingerprint: 'agenda:v1:native-stale', + memberName: 'alice', + teamName: 'team-a', + reportedAt: '2026-05-06T00:03:00.000Z', + accepted: true, + }, + }), + metrics: staleMetrics(), + }) + ).toEqual({ active: false, reason: 'status_not_nudgeable' }); + }); + + it('resets the stale native in-progress quiet window after a fingerprint change', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus(), + metrics: staleMetrics({ + generatedAt: '2026-05-06T00:08:59.000Z', + recentEvents: [ + { + id: 'old-same-fingerprint', + teamName: 'team-a', + memberName: 'alice', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + recordedAt: '2026-05-06T00:00:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + { + id: 'fingerprint-returned', + teamName: 'team-a', + memberName: 'alice', + kind: 'fingerprint_changed', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + previousFingerprint: 'agenda:v1:other', + recordedAt: '2026-05-06T00:03:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + { + id: 'current-after-change', + teamName: 'team-a', + memberName: 'alice', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + previousFingerprint: 'agenda:v1:other', + recordedAt: '2026-05-06T00:03:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + ], + }), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + }); + + it('activates stale native in-progress recovery after a returned fingerprint is stable long enough', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus(), + metrics: staleMetrics({ + generatedAt: '2026-05-06T00:09:00.000Z', + recentEvents: [ + { + id: 'old-same-fingerprint', + teamName: 'team-a', + memberName: 'alice', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + recordedAt: '2026-05-06T00:00:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + { + id: 'fingerprint-returned', + teamName: 'team-a', + memberName: 'alice', + kind: 'fingerprint_changed', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + previousFingerprint: 'agenda:v1:other', + recordedAt: '2026-05-06T00:03:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + { + id: 'current-after-change', + teamName: 'team-a', + memberName: 'alice', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + previousFingerprint: 'agenda:v1:other', + recordedAt: '2026-05-06T00:03:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + ], + }), + }) + ).toEqual({ active: true, reason: 'native_stale_in_progress' }); + }); + + it('does not activate stale native in-progress recovery from another member stale event', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus(), + metrics: staleMetrics({ + recentEvents: [ + { + id: 'other-member-status-stale', + teamName: 'team-a', + memberName: 'bob', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + recordedAt: '2026-05-06T00:00:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + ], + }), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + }); + + it('does not use native stale recovery for OpenCode or lead members', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus({ providerId: 'opencode' }), + metrics: staleMetrics(), + }) + ).toEqual({ active: true, reason: 'opencode_targeted_shadow_collecting' }); + + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus({ memberName: 'team-lead' }), + metrics: staleMetrics(), + }) + ).toEqual({ active: true, reason: 'lead_targeted_shadow_collecting' }); + }); + + it('does not activate stale native in-progress recovery for multiple or non-in-progress work items', () => { + const baseItem = nativeStaleInProgressStatus().agenda.items[0]!; + + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus({ + agenda: { + ...nativeStaleInProgressStatus().agenda, + items: [ + baseItem, + { + ...baseItem, + taskId: 'task-2', + }, + ], + }, + }), + metrics: staleMetrics(), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus({ + agenda: { + ...nativeStaleInProgressStatus().agenda, + items: [ + { + ...baseItem, + reason: 'owned_pending_task', + evidence: { + status: 'pending', + owner: 'alice', + }, + }, + ], + }, + }), + metrics: staleMetrics(), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + }); + + it('does not activate stale native in-progress recovery for needsFix, review, blocked dependency, or clarification agenda items', () => { + const baseItem = nativeStaleInProgressStatus().agenda.items[0]!; + const cases = [ + { + ...baseItem, + evidence: { + status: 'needsFix', + owner: 'alice', + }, + }, + { + ...baseItem, + kind: 'review' as const, + priority: 'review_requested' as const, + reason: 'current_cycle_review_assigned', + evidence: { + status: 'completed', + owner: 'bob', + reviewer: 'alice', + reviewState: 'review', + reviewCycleId: 'evt-review-request', + reviewRequestEventId: 'evt-review-request', + reviewObligation: 'review_pickup_required' as const, + canBypassPhase2: true, + historyEventIds: ['evt-review-request'], + }, + }, + { + ...baseItem, + kind: 'blocked_dependency' as const, + priority: 'blocked' as const, + reason: 'blocked_by_incomplete_task', + evidence: { + status: 'in_progress', + owner: 'alice', + blockerTaskIds: ['task-blocker'], + }, + }, + { + ...baseItem, + kind: 'clarification' as const, + priority: 'needs_clarification' as const, + reason: 'lead_clarification_required', + evidence: { + status: 'in_progress', + owner: 'alice', + needsClarification: 'lead' as const, + }, + }, + ]; + + for (const item of cases) { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus({ + agenda: { + ...nativeStaleInProgressStatus().agenda, + items: [item], + }, + }), + metrics: staleMetrics(), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + } + }); + it('keeps existing shadow_ready behavior for all providers', () => { expect( decideMemberWorkSyncNudgeActivation({ diff --git a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts index 7ce261db..c5ca1465 100644 --- a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts +++ b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts @@ -179,6 +179,69 @@ async function seedBlockingShadowCollectingMetrics(input: { ); } +async function seedNativeStaleInProgressBlockingMetrics(input: { + teamsBasePath: string; + teamName: string; + memberName: string; + agendaFingerprint: string; +}): Promise { + const nowMs = Date.now(); + const staleObservedAt = new Date(nowMs - 6 * 60_000 - 1_000).toISOString(); + const metricsPath = path.join( + input.teamsBasePath, + input.teamName, + '.member-work-sync', + 'indexes', + 'metrics.json' + ); + await fs.promises.mkdir(path.dirname(metricsPath), { recursive: true }); + await fs.promises.writeFile( + metricsPath, + `${JSON.stringify( + { + schemaVersion: 2, + members: { + [input.memberName]: { + memberName: input.memberName, + state: 'needs_sync', + agendaFingerprint: input.agendaFingerprint, + actionableCount: 1, + evaluatedAt: staleObservedAt, + providerId: 'codex', + }, + }, + recentEvents: [ + { + id: 'native-stale-status', + teamName: input.teamName, + memberName: input.memberName, + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: input.agendaFingerprint, + recordedAt: staleObservedAt, + actionableCount: 1, + providerId: 'codex', + }, + ...Array.from({ length: 12 }, (_, index) => ({ + id: `native-stale-would-nudge-${index}`, + teamName: input.teamName, + memberName: input.memberName, + kind: 'would_nudge', + state: 'needs_sync', + agendaFingerprint: input.agendaFingerprint, + recordedAt: new Date(nowMs - 5 * 60_000 + index * 5_000).toISOString(), + actionableCount: 1, + providerId: 'codex', + })), + ], + }, + null, + 2 + )}\n`, + 'utf8' + ); +} + async function waitForAssertion(assertion: () => Promise | void): Promise { const deadline = Date.now() + 5_000; let lastError: unknown; @@ -1067,6 +1130,130 @@ describe('createMemberWorkSyncFeature composition', () => { } }); + it('delivers native stale in-progress recovery nudges despite noisy global metrics', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-native-stale-in-progress'; + const memberName = 'alice'; + const nudgeDeliveryWake = { + schedule: vi.fn(async () => undefined), + }; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'codex' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Review landing', + status: 'in_progress', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + nudgeDeliveryWake, + queueQuietWindowMs: 1, + }); + + try { + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + let agendaFingerprint = ''; + await waitForAssertion(async () => { + const status = await feature.getStatus({ teamName, memberName }); + expect(status).toMatchObject({ + state: 'needs_sync', + providerId: 'codex', + diagnostics: expect.arrayContaining(['no_current_report']), + agenda: { + items: [ + expect.objectContaining({ + reason: 'owned_in_progress_task', + evidence: expect.objectContaining({ status: 'in_progress' }), + }), + ], + }, + }); + agendaFingerprint = status.agenda.fingerprint; + }); + expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]); + expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled(); + + await seedNativeStaleInProgressBlockingMetrics({ + teamsBasePath, + teamName, + memberName, + agendaFingerprint, + }); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(1); + expect(nudges[0]?.text).toContain('Work sync check'); + expect(nudges[0]?.text).toContain('11111111'); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({ + teamName, + memberName, + messageId: nudges[0]?.messageId, + providerId: 'codex', + reason: 'member_work_sync_nudge_inserted', + delayMs: 500, + }); + await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ + phase2Readiness: { + reasons: expect.arrayContaining(['would_nudge_rate_high']), + }, + }); + expect( + Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) + ).toEqual([ + expect.objectContaining({ + status: 'delivered', + deliveredMessageId: nudges[0]?.messageId, + }), + ]); + }); + + const journal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + expect(journal).toContain('"event":"nudge_delivered"'); + expect(journal).toContain('"reason":"created"'); + } finally { + await feature.dispose(); + } + }); + it('delivers targeted OpenCode nudges even when global phase2 metrics are noisy', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot);