feat(member-work-sync): add proof missing recovery contracts

This commit is contained in:
777genius 2026-05-14 01:26:29 +03:00
parent 75a3938f84
commit 6aad8b9c2c
8 changed files with 236 additions and 1 deletions

View file

@ -31,6 +31,18 @@ export async function appendMemberWorkSyncAudit(
}
export function reasonToAuditEvent(reason: string): MemberWorkSyncAuditEventName {
if (reason === 'proof_missing_recovery_scheduled') {
return 'proof_missing_recovery_scheduled';
}
if (reason === 'proof_missing_recovery_coalesced') {
return 'proof_missing_recovery_coalesced';
}
if (reason === 'proof_missing_recovery_suppressed') {
return 'proof_missing_recovery_suppressed';
}
if (reason === 'proof_missing_recovery_conflict') {
return 'proof_missing_recovery_conflict';
}
if (reason.startsWith('member_busy:')) {
return 'member_busy';
}

View file

@ -94,7 +94,11 @@ export type MemberWorkSyncAuditEventName =
| 'member_busy'
| 'team_inactive'
| 'index_repaired'
| 'legacy_fallback_used';
| 'legacy_fallback_used'
| 'proof_missing_recovery_scheduled'
| 'proof_missing_recovery_coalesced'
| 'proof_missing_recovery_suppressed'
| 'proof_missing_recovery_conflict';
export interface MemberWorkSyncAuditEvent {
timestamp: string;
@ -161,6 +165,18 @@ export interface MemberWorkSyncOutboxStorePort {
memberName: string;
reviewRequestEventIds: string[];
}): Promise<string[]>;
findRecentRecoveryByIntent?(input: {
teamName: string;
memberName: string;
intentKey: string;
sinceIso: string;
}): Promise<{
id: string;
status: MemberWorkSyncOutboxItem['status'];
deliveredMessageId?: string;
payloadHash: string;
updatedAt: string;
} | null>;
}
export interface MemberWorkSyncInboxNudgePort {

View file

@ -980,6 +980,46 @@ export class JsonMemberWorkSyncStore
return [...delivered].sort();
}
async findRecentRecoveryByIntent(input: {
teamName: string;
memberName: string;
intentKey: string;
sinceIso: string;
}): Promise<{
id: string;
status: MemberWorkSyncOutboxItem['status'];
deliveredMessageId?: string;
payloadHash: string;
updatedAt: string;
} | null> {
const intentKey = input.intentKey.trim();
if (!intentKey) {
return null;
}
const memberOutbox = await this.readMemberOutboxFile(input.teamName, input.memberName);
const matches = Object.values(memberOutbox.items)
.filter(
(item) =>
item.payload.workSyncIntentKey === intentKey &&
item.updatedAt >= input.sinceIso &&
item.status !== 'failed_terminal'
)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
const latest = matches[0];
if (!latest) {
return null;
}
return {
id: latest.id,
status: latest.status,
...(latest.deliveredMessageId ? { deliveredMessageId: latest.deliveredMessageId } : {}),
payloadHash: latest.payloadHash,
updatedAt: latest.updatedAt,
};
}
private async readLegacyStatusFile(teamName: string): Promise<LegacyStatusFile> {
return readJsonFile(
this.paths.getLegacyStatusPath(teamName),

View file

@ -14,6 +14,7 @@ export type MemberWorkSyncTriggerReason =
| 'tool_finished'
| 'runtime_activity'
| 'turn_settled'
| 'proof_missing_recovery'
| 'manual_refresh';
export interface MemberWorkSyncQueueDiagnostics {
@ -461,6 +462,8 @@ function defaultRunAfterMs(reason: MemberWorkSyncTriggerReason): number {
switch (reason) {
case 'manual_refresh':
return 0;
case 'proof_missing_recovery':
return 5_000;
case 'turn_settled':
case 'tool_finished':
return 5_000;
@ -479,6 +482,8 @@ function defaultMaxCoalesceWaitMs(reason: MemberWorkSyncTriggerReason): number {
switch (reason) {
case 'manual_refresh':
return 0;
case 'proof_missing_recovery':
return 30_000;
case 'turn_settled':
case 'tool_finished':
return 30_000;

View file

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { reasonToAuditEvent } from '@features/member-work-sync/core/application/MemberWorkSyncAudit';
describe('MemberWorkSyncAudit', () => {
it('maps proof-missing recovery reasons to typed audit events', () => {
expect(reasonToAuditEvent('proof_missing_recovery_scheduled')).toBe(
'proof_missing_recovery_scheduled'
);
expect(reasonToAuditEvent('proof_missing_recovery_coalesced')).toBe(
'proof_missing_recovery_coalesced'
);
expect(reasonToAuditEvent('proof_missing_recovery_suppressed')).toBe(
'proof_missing_recovery_suppressed'
);
expect(reasonToAuditEvent('proof_missing_recovery_conflict')).toBe(
'proof_missing_recovery_conflict'
);
});
});

View file

@ -54,6 +54,33 @@ describe('FileMemberWorkSyncAuditJournal', () => {
});
});
it('accepts typed proof-missing recovery audit events', async () => {
const journal = new FileMemberWorkSyncAuditJournal(new MemberWorkSyncStorePaths(root));
await journal.append({
timestamp: '2026-04-30T00:00:00.000Z',
teamName: 'team-a',
memberName: 'bob',
event: 'proof_missing_recovery_scheduled',
source: 'test',
reason: 'protocol_proof_missing',
metadata: {
originalMessageId: 'message-1',
intentKey: 'proof-missing:message-1',
},
});
const [line] = (await readFile(journalPath(root), 'utf8')).trim().split('\n');
expect(JSON.parse(line)).toMatchObject({
event: 'proof_missing_recovery_scheduled',
reason: 'protocol_proof_missing',
metadata: {
originalMessageId: 'message-1',
intentKey: 'proof-missing:message-1',
},
});
});
it('truncates previews and rotates bounded journals', async () => {
const journal = new FileMemberWorkSyncAuditJournal(
new MemberWorkSyncStorePaths(root),

View file

@ -430,6 +430,95 @@ describe('JsonMemberWorkSyncStore', () => {
});
});
it('finds recent recovery outbox rows by logical intent key', async () => {
const olderInput = {
id: 'member-work-sync:team-a:bob:agenda:v1:older',
teamName: 'team-a',
memberName: 'bob',
agendaFingerprint: 'agenda:v1:older',
payloadHash: 'hash-older',
payload: makeNudgePayload({ workSyncIntentKey: 'proof-missing:message-1' }),
nowIso: '2026-04-29T00:00:00.000Z',
};
const latestInput = {
...olderInput,
id: 'member-work-sync:team-a:bob:agenda:v1:latest',
agendaFingerprint: 'agenda:v1:latest',
payloadHash: 'hash-latest',
nowIso: '2026-04-29T00:03:00.000Z',
};
const unrelatedInput = {
...olderInput,
id: 'member-work-sync:team-a:bob:agenda:v1:unrelated',
agendaFingerprint: 'agenda:v1:unrelated',
payloadHash: 'hash-unrelated',
payload: makeNudgePayload({ workSyncIntentKey: 'proof-missing:message-2' }),
nowIso: '2026-04-29T00:04:00.000Z',
};
await store.ensurePending(olderInput);
await store.ensurePending(latestInput);
await store.ensurePending(unrelatedInput);
await expect(
store.findRecentRecoveryByIntent({
teamName: 'team-a',
memberName: 'bob',
intentKey: 'proof-missing:message-1',
sinceIso: '2026-04-29T00:01:00.000Z',
})
).resolves.toMatchObject({
id: latestInput.id,
status: 'pending',
payloadHash: 'hash-latest',
updatedAt: '2026-04-29T00:03:00.000Z',
});
});
it('ignores terminal and stale rows for logical recovery lookup', async () => {
const input = {
id: 'member-work-sync:team-a:bob:agenda:v1:terminal',
teamName: 'team-a',
memberName: 'bob',
agendaFingerprint: 'agenda:v1:terminal',
payloadHash: 'hash-a',
payload: makeNudgePayload({ workSyncIntentKey: 'proof-missing:message-1' }),
nowIso: '2026-04-29T00:00:00.000Z',
};
await store.ensurePending(input);
const [claimed] = await store.claimDue({
teamName: 'team-a',
claimedBy: 'dispatcher-a',
nowIso: '2026-04-29T00:01:00.000Z',
limit: 1,
});
await store.markFailed({
teamName: 'team-a',
id: input.id,
attemptGeneration: claimed.attemptGeneration,
error: 'inbox_payload_conflict',
retryable: false,
nowIso: '2026-04-29T00:02:00.000Z',
});
await expect(
store.findRecentRecoveryByIntent({
teamName: 'team-a',
memberName: 'bob',
intentKey: 'proof-missing:message-1',
sinceIso: '2026-04-29T00:00:00.000Z',
})
).resolves.toBeNull();
await expect(
store.findRecentRecoveryByIntent({
teamName: 'team-a',
memberName: 'bob',
intentKey: 'proof-missing:message-1',
sinceIso: '2026-04-29T00:03:00.000Z',
})
).resolves.toBeNull();
});
it('claims due outbox items and fences terminal updates by attempt generation', async () => {
const input = {
id: 'member-work-sync:team-a:bob:agenda:v1:abc',

View file

@ -124,6 +124,32 @@ describe('MemberWorkSyncEventQueue', () => {
await queue.stop();
});
it('uses explicit fast timing for proof-missing recovery triggers', async () => {
const reconciles: unknown[] = [];
const queue = new MemberWorkSyncEventQueue({
reconcile: async (request, context) => {
reconciles.push({ request, context });
},
isTeamActive: () => true,
});
queue.enqueue({
teamName: 'team-a',
memberName: 'bob',
triggerReason: 'proof_missing_recovery',
});
await vi.advanceTimersByTimeAsync(4_999);
expect(reconciles).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1);
expect(reconciles).toHaveLength(1);
expect(reconciles[0]).toMatchObject({
context: { triggerReasons: ['proof_missing_recovery'] },
});
await queue.stop();
});
it('does not let a later quiet-window event delay a queued manual refresh', async () => {
const reconciles: unknown[] = [];
const queue = new MemberWorkSyncEventQueue({