diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncAudit.ts b/src/features/member-work-sync/core/application/MemberWorkSyncAudit.ts index f2a52b09..ab2be3cd 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncAudit.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncAudit.ts @@ -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'; } diff --git a/src/features/member-work-sync/core/application/ports.ts b/src/features/member-work-sync/core/application/ports.ts index 12bf7540..7d45b0cd 100644 --- a/src/features/member-work-sync/core/application/ports.ts +++ b/src/features/member-work-sync/core/application/ports.ts @@ -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; + 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 { diff --git a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts index 245db293..c35f1c97 100644 --- a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts +++ b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts @@ -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 { return readJsonFile( this.paths.getLegacyStatusPath(teamName), diff --git a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts index 074851d6..2101c294 100644 --- a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts +++ b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts @@ -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; diff --git a/test/features/member-work-sync/core/MemberWorkSyncAudit.test.ts b/test/features/member-work-sync/core/MemberWorkSyncAudit.test.ts new file mode 100644 index 00000000..95b75fe5 --- /dev/null +++ b/test/features/member-work-sync/core/MemberWorkSyncAudit.test.ts @@ -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' + ); + }); +}); diff --git a/test/features/member-work-sync/main/FileMemberWorkSyncAuditJournal.test.ts b/test/features/member-work-sync/main/FileMemberWorkSyncAuditJournal.test.ts index a559e0aa..3cb3edd7 100644 --- a/test/features/member-work-sync/main/FileMemberWorkSyncAuditJournal.test.ts +++ b/test/features/member-work-sync/main/FileMemberWorkSyncAuditJournal.test.ts @@ -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), diff --git a/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts index 4628d00a..502a6aa4 100644 --- a/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts +++ b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts @@ -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', diff --git a/test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts b/test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts index 44aa5cf8..f99efd77 100644 --- a/test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts +++ b/test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts @@ -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({