fix(member-work-sync): recover stale native in-progress tasks
This commit is contained in:
parent
69572150c9
commit
4cb1b6ef5f
3 changed files with 686 additions and 1 deletions
|
|
@ -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' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,62 @@ function metrics(overrides: Partial<MemberWorkSyncTeamMetrics> = {}): MemberWork
|
|||
};
|
||||
}
|
||||
|
||||
function nativeStaleInProgressStatus(
|
||||
overrides: Partial<MemberWorkSyncStatus> = {}
|
||||
): 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> = {}
|
||||
): 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({
|
||||
|
|
|
|||
|
|
@ -179,6 +179,69 @@ async function seedBlockingShadowCollectingMetrics(input: {
|
|||
);
|
||||
}
|
||||
|
||||
async function seedNativeStaleInProgressBlockingMetrics(input: {
|
||||
teamsBasePath: string;
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
agendaFingerprint: string;
|
||||
}): Promise<void> {
|
||||
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> | void): Promise<void> {
|
||||
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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue