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,
|
type MemberWorkSyncTargetedRecoveryReason,
|
||||||
} from './MemberWorkSyncTargetedRecoveryPolicy';
|
} from './MemberWorkSyncTargetedRecoveryPolicy';
|
||||||
|
|
||||||
import type { MemberWorkSyncStatus, MemberWorkSyncTeamMetrics } from '../../contracts';
|
import type {
|
||||||
|
MemberWorkSyncMetricEvent,
|
||||||
|
MemberWorkSyncStatus,
|
||||||
|
MemberWorkSyncTeamMetrics,
|
||||||
|
} from '../../contracts';
|
||||||
|
|
||||||
export type MemberWorkSyncNudgeActivationReason =
|
export type MemberWorkSyncNudgeActivationReason =
|
||||||
| 'shadow_ready'
|
| 'shadow_ready'
|
||||||
| MemberWorkSyncTargetedRecoveryReason
|
| MemberWorkSyncTargetedRecoveryReason
|
||||||
| 'review_pickup_required'
|
| 'review_pickup_required'
|
||||||
|
| 'native_stale_in_progress'
|
||||||
| 'status_not_nudgeable'
|
| 'status_not_nudgeable'
|
||||||
| 'blocking_metrics'
|
| 'blocking_metrics'
|
||||||
| 'phase2_not_ready';
|
| '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 {
|
export interface MemberWorkSyncNudgeActivationDecision {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
reason: MemberWorkSyncNudgeActivationReason;
|
reason: MemberWorkSyncNudgeActivationReason;
|
||||||
|
|
@ -32,6 +40,129 @@ function hasBlockingMetrics(metrics: MemberWorkSyncTeamMetrics): boolean {
|
||||||
return metrics.phase2Readiness.reasons.some((reason) => BLOCKING_PHASE2_REASONS.has(reason));
|
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 {
|
function isReviewPickupRequiredCandidate(status: MemberWorkSyncStatus): boolean {
|
||||||
return (
|
return (
|
||||||
status.state === 'needs_sync' &&
|
status.state === 'needs_sync' &&
|
||||||
|
|
@ -61,6 +192,10 @@ export function decideMemberWorkSyncNudgeActivation(input: {
|
||||||
return { active: true, reason: targetedRecovery.reason };
|
return { active: true, reason: targetedRecovery.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isNativeStaleInProgressCandidate(input)) {
|
||||||
|
return { active: true, reason: 'native_stale_in_progress' };
|
||||||
|
}
|
||||||
|
|
||||||
if (hasBlockingMetrics(input.metrics)) {
|
if (hasBlockingMetrics(input.metrics)) {
|
||||||
return { active: false, reason: 'blocking_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', () => {
|
describe('MemberWorkSyncNudgeActivationPolicy', () => {
|
||||||
it('activates OpenCode targeted nudges while shadow data is still collecting', () => {
|
it('activates OpenCode targeted nudges while shadow data is still collecting', () => {
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -348,6 +404,313 @@ describe('MemberWorkSyncNudgeActivationPolicy', () => {
|
||||||
).toEqual({ active: false, reason: 'blocking_metrics' });
|
).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', () => {
|
it('keeps existing shadow_ready behavior for all providers', () => {
|
||||||
expect(
|
expect(
|
||||||
decideMemberWorkSyncNudgeActivation({
|
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> {
|
async function waitForAssertion(assertion: () => Promise<void> | void): Promise<void> {
|
||||||
const deadline = Date.now() + 5_000;
|
const deadline = Date.now() + 5_000;
|
||||||
let lastError: unknown;
|
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 () => {
|
it('delivers targeted OpenCode nudges even when global phase2 metrics are noisy', async () => {
|
||||||
const claudeRoot = makeTempRoot();
|
const claudeRoot = makeTempRoot();
|
||||||
setClaudeBasePathOverride(claudeRoot);
|
setClaudeBasePathOverride(claudeRoot);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue