fix(member-work-sync): recover stale native in-progress tasks

This commit is contained in:
777genius 2026-05-16 12:35:06 +03:00
parent 69572150c9
commit 4cb1b6ef5f
3 changed files with 686 additions and 1 deletions

View file

@ -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' };
}

View file

@ -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({

View file

@ -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);