diff --git a/src/features/member-work-sync/contracts/types.ts b/src/features/member-work-sync/contracts/types.ts index 3b76879f..d2597252 100644 --- a/src/features/member-work-sync/contracts/types.ts +++ b/src/features/member-work-sync/contracts/types.ts @@ -104,6 +104,12 @@ export interface MemberWorkSyncShadowDiagnostics { fingerprintChanged: boolean; previousFingerprint?: string; triggerReasons?: string[]; + recovery?: { + kind: 'proof_missing'; + intentKey: string; + originalMessageId: string; + taskIds: string[]; + }; } export interface MemberWorkSyncStatus { diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts index 0ac4f068..cb6e18d3 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts @@ -14,6 +14,12 @@ import type { MemberWorkSyncAgendaSourceResult, MemberWorkSyncUseCaseDeps } from export interface MemberWorkSyncReconcileContext { reconciledBy?: 'request' | 'queue'; triggerReasons?: string[]; + recovery?: { + kind: 'proof_missing'; + intentKey: string; + originalMessageId: string; + taskIds?: string[]; + }; } export function finalizeMemberWorkSyncAgenda( @@ -107,6 +113,16 @@ export class MemberWorkSyncReconciler { ...(context.triggerReasons?.length ? { triggerReasons: [...new Set(context.triggerReasons)].sort() } : {}), + ...(context.recovery + ? { + recovery: { + kind: context.recovery.kind, + intentKey: context.recovery.intentKey, + originalMessageId: context.recovery.originalMessageId, + taskIds: [...new Set(context.recovery.taskIds ?? [])].sort(), + }, + } + : {}), }, evaluatedAt: nowIso, diagnostics: [ diff --git a/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts b/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts index 17a05cca..595926e5 100644 --- a/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts +++ b/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts @@ -94,6 +94,18 @@ function hasLeadClarificationItem(status: MemberWorkSyncStatus): boolean { ); } +function buildProofMissingRecoveryText(status: MemberWorkSyncStatus): string[] { + const recovery = status.shadow?.recovery; + if (recovery?.kind !== 'proof_missing') { + return []; + } + + return [ + `This also repairs OpenCode delivery proof for original messageId "${recovery.originalMessageId}".`, + 'If you already completed the work, do not duplicate it; instead create the missing visible reply or task progress proof for the current agenda.', + ]; +} + function buildReviewPickupNudgePayload(status: MemberWorkSyncStatus): MemberWorkSyncNudgePayload { const taskRefs = buildTaskRefs(status); const preview = buildAgendaPreview(status); @@ -148,9 +160,13 @@ export function buildMemberWorkSyncNudgePayload( source: 'member-work-sync', actionMode: 'do', workSyncIntent: 'agenda_sync', + ...(status.shadow?.recovery?.intentKey + ? { workSyncIntentKey: status.shadow.recovery.intentKey } + : {}), taskRefs, text: [ 'Work sync check: you have current actionable work assigned.', + ...buildProofMissingRecoveryText(status), preview ? `Current agenda: ${preview}.` : '', `Required sync action: call member_work_sync_status with teamName "${status.teamName}" and memberName "${status.memberName}", then call member_work_sync_report with the same teamName/memberName and the returned agendaFingerprint and reportToken.`, taskIds.length @@ -191,8 +207,7 @@ export function buildMemberWorkSyncOutboxEnsureInput(input: { } const payload = buildMemberWorkSyncNudgePayload(status); - const intentKey = - payload.workSyncIntent === 'review_pickup' ? payload.workSyncIntentKey : undefined; + const intentKey = payload.workSyncIntentKey; return { id: buildMemberWorkSyncNudgeId({ teamName: status.teamName, diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index 6fae365e..ad994707 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -62,6 +62,7 @@ import type { TeamTaskReader } from '@main/services/team/TeamTaskReader'; import type { TeamChangeEvent } from '@shared/types'; const STALE_STATUS_MAX_AGE_MS = 2 * 60_000; +const PROOF_MISSING_RECOVERY_RECENT_WINDOW_MS = 10 * 60_000; function getStatusStalenessDiagnostics(status: MemberWorkSyncStatus, nowMs: number): string[] { const diagnostics: string[] = []; @@ -103,6 +104,9 @@ export interface MemberWorkSyncFeatureFacade { refreshStatus(request: MemberWorkSyncStatusRequest): Promise; getMetrics(request: MemberWorkSyncMetricsRequest): Promise; report(request: MemberWorkSyncReportRequest): Promise; + scheduleProofMissingRecovery( + request: MemberWorkSyncProofMissingRecoveryScheduleRequest + ): Promise; noteTeamChange(event: TeamChangeEvent): void; enqueueStartupScan(teamNames: string[]): Promise; replayPendingReports(teamNames: string[]): Promise; @@ -118,6 +122,45 @@ export interface MemberWorkSyncFeatureFacade { dispose(): Promise; } +export interface MemberWorkSyncProofMissingRecoveryScheduleRequest { + teamName: string; + memberName: string; + originalMessageId: string; + taskRefs?: { taskId: string; displayId?: string; teamName?: string }[]; + reason?: string; +} + +export interface MemberWorkSyncProofMissingRecoveryScheduleResult { + scheduled: boolean; + reason: 'scheduled' | 'coalesced_recent' | 'invalid'; + intentKey?: string; + existingOutboxId?: string; +} + +function buildProofMissingRecoveryIntentKey(originalMessageId: string): string { + return `proof-missing:${originalMessageId}`; +} + +function normalizeRecoveryTaskRefs( + taskRefs: MemberWorkSyncProofMissingRecoveryScheduleRequest['taskRefs'] +): { taskId: string; displayId?: string; teamName?: string }[] { + const seen = new Set(); + const normalized: { taskId: string; displayId?: string; teamName?: string }[] = []; + for (const taskRef of taskRefs ?? []) { + const taskId = taskRef.taskId.trim(); + if (!taskId || seen.has(taskId)) { + continue; + } + seen.add(taskId); + normalized.push({ + taskId, + ...(taskRef.displayId?.trim() ? { displayId: taskRef.displayId.trim() } : {}), + ...(taskRef.teamName?.trim() ? { teamName: taskRef.teamName.trim() } : {}), + }); + } + return normalized.sort((left, right) => left.taskId.localeCompare(right.taskId)); +} + export function createMemberWorkSyncFeature(deps: { teamsBasePath: string; configReader: TeamConfigReader; @@ -329,11 +372,82 @@ export function createMemberWorkSyncFeature(deps: { }; }; + const scheduleProofMissingRecovery = async ( + request: MemberWorkSyncProofMissingRecoveryScheduleRequest + ): Promise => { + const teamName = request.teamName.trim(); + const memberName = request.memberName.trim(); + const originalMessageId = request.originalMessageId.trim(); + if (!teamName || !memberName || !originalMessageId) { + return { scheduled: false, reason: 'invalid' }; + } + + const intentKey = buildProofMissingRecoveryIntentKey(originalMessageId); + const sinceIso = new Date( + clock.now().getTime() - PROOF_MISSING_RECOVERY_RECENT_WINDOW_MS + ).toISOString(); + const existing = await store.findRecentRecoveryByIntent?.({ + teamName, + memberName, + intentKey, + sinceIso, + }); + if (existing) { + await auditJournal.append({ + timestamp: clock.now().toISOString(), + teamName, + memberName, + event: 'proof_missing_recovery_coalesced', + source: 'proof_missing_recovery_scheduler', + reason: existing.status, + metadata: { + intentKey, + originalMessageId, + existingOutboxId: existing.id, + }, + }); + return { + scheduled: false, + reason: 'coalesced_recent', + intentKey, + existingOutboxId: existing.id, + }; + } + + const taskRefs = normalizeRecoveryTaskRefs(request.taskRefs); + await auditJournal.append({ + timestamp: clock.now().toISOString(), + teamName, + memberName, + event: 'proof_missing_recovery_scheduled', + source: 'proof_missing_recovery_scheduler', + reason: request.reason?.trim() || 'protocol_proof_missing', + taskRefs, + metadata: { + intentKey, + originalMessageId, + }, + }); + queue.enqueue({ + teamName, + memberName, + triggerReason: 'proof_missing_recovery', + recovery: { + kind: 'proof_missing', + intentKey, + originalMessageId, + taskIds: taskRefs.map((taskRef) => taskRef.taskId), + }, + }); + return { scheduled: true, reason: 'scheduled', intentKey }; + }; + return { getStatus: readStatusWithStaleRefresh, refreshStatus: (request) => reconciler.execute(request, { reconciledBy: 'request' }), getMetrics: (request) => metricsReader.execute(request), report: (request) => reporter.execute(request), + scheduleProofMissingRecovery, noteTeamChange: (event) => { toolActivityBusySignal.noteTeamChange(event); router.noteTeamChange(event); diff --git a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts index 2101c294..7365c92f 100644 --- a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts +++ b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts @@ -61,6 +61,7 @@ interface QueueItem { maxRunAt: number; triggerReasons: Set; triggerReasonCounts: Map; + recovery?: MemberWorkSyncReconcileContext['recovery']; } interface RunningItem { @@ -69,6 +70,7 @@ interface RunningItem { startedAt: number; rerunRequested: boolean; triggerReasons: Set; + recovery?: MemberWorkSyncReconcileContext['recovery']; } interface TriggerTimingPolicy { @@ -152,6 +154,7 @@ export class MemberWorkSyncEventQueue { memberName: string; triggerReason: MemberWorkSyncTriggerReason; runAfterMs?: number; + recovery?: MemberWorkSyncReconcileContext['recovery']; }): void { if (this.stopped) { return; @@ -172,6 +175,9 @@ export class MemberWorkSyncEventQueue { if (running) { running.rerunRequested = true; running.triggerReasons.add(input.triggerReason); + if (input.recovery) { + running.recovery = input.recovery; + } this.counters.coalesced += 1; this.appendAudit({ teamName, @@ -186,6 +192,9 @@ export class MemberWorkSyncEventQueue { const existing = this.items.get(key); if (existing) { existing.triggerReasons.add(input.triggerReason); + if (input.recovery) { + existing.recovery = input.recovery; + } existing.lastQueuedAt = now; existing.maxRunAt = Math.max( existing.maxRunAt, @@ -221,6 +230,7 @@ export class MemberWorkSyncEventQueue { maxRunAt: now + timing.maxCoalesceWaitMs, triggerReasons: new Set([input.triggerReason]), triggerReasonCounts: new Map([[input.triggerReason, 1]]), + ...(input.recovery ? { recovery: input.recovery } : {}), }); this.counters.enqueued += 1; this.appendAudit({ @@ -352,6 +362,7 @@ export class MemberWorkSyncEventQueue { startedAt: this.now(), rerunRequested: false, triggerReasons: new Set(item.triggerReasons), + ...(item.recovery ? { recovery: item.recovery } : {}), }; this.running.set(key, running); @@ -378,6 +389,7 @@ export class MemberWorkSyncEventQueue { private enqueueFollowUp(item: QueueItem, running: RunningItem): void { const reasons = [...running.triggerReasons].sort(); + const recovery = running.recovery ?? item.recovery; const primaryReason = reasons.find((reason) => reason === 'manual_refresh') ?? reasons.find((reason) => reason === 'turn_settled' || reason === 'tool_finished') ?? @@ -388,6 +400,7 @@ export class MemberWorkSyncEventQueue { memberName: item.memberName, triggerReason: primaryReason, runAfterMs: Math.min(this.resolveTimingPolicy(primaryReason).runAfterMs, 5_000), + ...(recovery ? { recovery } : {}), }); const queued = this.items.get(keyOf(item.teamName, item.memberName)); if (!queued) { @@ -414,11 +427,13 @@ export class MemberWorkSyncEventQueue { return; } + const recovery = running.recovery ?? item.recovery; await this.deps.reconcile( { teamName: item.teamName, memberName: item.memberName }, { reconciledBy: 'queue', triggerReasons: [...running.triggerReasons].sort(), + ...(recovery ? { recovery } : {}), } ); this.counters.reconciled += 1; diff --git a/src/main/index.ts b/src/main/index.ts index 103ac0b9..4e5569fd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1830,6 +1830,11 @@ async function initializeServices(): Promise { ? memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment(input) : Promise.resolve(null) ); + teamProvisioningService.setMemberWorkSyncProofMissingRecoveryScheduler((input) => + memberWorkSyncFeature + ? memberWorkSyncFeature.scheduleProofMissingRecovery(input) + : Promise.resolve({ scheduled: false, reason: 'invalid' }) + ); scheduleStartupTask(() => { void teamDataService .listTeams() diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 054cd3cc..2b582c67 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -5484,6 +5484,14 @@ interface OpenCodeMemberInboxRelayOptions { }; } +type MemberWorkSyncProofMissingRecoveryScheduler = (input: { + teamName: string; + memberName: string; + originalMessageId: string; + taskRefs?: TaskRef[]; + reason?: string; +}) => Promise | unknown; + function normalizeSameTeamText(text: string): string { return text.trim().replace(/\r\n/g, '\n'); } @@ -5643,6 +5651,8 @@ export class TeamProvisioningService { private memberRuntimeAdvisoryInvalidator: | ((teamName: string, memberName: string) => void) | null = null; + private memberWorkSyncProofMissingRecoveryScheduler: MemberWorkSyncProofMissingRecoveryScheduler | null = + null; private readonly memberLogsFinder: TeamMemberLogsFinder; private readonly transcriptProjectResolver: TeamTranscriptProjectResolver; private readonly taskActivityIntervalService = new TeamTaskActivityIntervalService(); @@ -6002,6 +6012,12 @@ export class TeamProvisioningService { this.memberRuntimeAdvisoryInvalidator = invalidator; } + setMemberWorkSyncProofMissingRecoveryScheduler( + scheduler: MemberWorkSyncProofMissingRecoveryScheduler | null + ): void { + this.memberWorkSyncProofMissingRecoveryScheduler = scheduler; + } + setCrossTeamSender( sender: | ((request: { @@ -8827,6 +8843,7 @@ export class TeamProvisioningService { } this.emitOpenCodeRuntimeDeliveryAdvisoryEvent(latestRecord, decision); + await this.scheduleOpenCodeProofMissingWorkSyncRecovery(latestRecord, decision); if (decision.severity !== 'error') { return; } @@ -8932,6 +8949,33 @@ export class TeamProvisioningService { }); } + private async scheduleOpenCodeProofMissingWorkSyncRecovery( + record: OpenCodePromptDeliveryLedgerRecord, + decision: OpenCodeRuntimeDeliveryAdvisoryDecision + ): Promise { + if (decision.reasonCode !== 'protocol_proof_missing') { + return; + } + const scheduler = this.memberWorkSyncProofMissingRecoveryScheduler; + if (!scheduler) { + return; + } + + try { + await scheduler({ + teamName: record.teamName, + memberName: record.memberName, + originalMessageId: record.inboxMessageId, + taskRefs: record.taskRefs, + ...(decision.reason ? { reason: decision.reason } : {}), + }); + } catch (error) { + logger.warn( + `[${record.teamName}] Failed to schedule OpenCode proof-missing work sync recovery for ${record.memberName}: ${getErrorMessage(error)}` + ); + } + } + private emitOpenCodeRuntimeDeliveryAdvisoryEvent( record: OpenCodePromptDeliveryLedgerRecord, decision?: OpenCodeRuntimeDeliveryAdvisoryDecision diff --git a/test/features/member-work-sync/core/MemberWorkSyncNudge.test.ts b/test/features/member-work-sync/core/MemberWorkSyncNudge.test.ts index 86287706..f434f9c8 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncNudge.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncNudge.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { buildMemberWorkSyncNudgePayload } from '@features/member-work-sync/core/domain'; +import { + buildMemberWorkSyncNudgePayload, + buildMemberWorkSyncOutboxEnsureInput, +} from '@features/member-work-sync/core/domain'; import type { MemberWorkSyncStatus } from '@features/member-work-sync/contracts'; function makeStatus( @@ -86,4 +89,61 @@ describe('MemberWorkSyncNudge', () => { expect(payload.text).not.toContain('task_set_clarification value "user"'); }); + + it('adds proof-missing recovery context to agenda sync nudges', () => { + const status = makeStatus({ + memberName: 'bob', + agenda: { + teamName: 'sable-ops', + memberName: 'bob', + generatedAt: '2026-05-13T13:02:44.263Z', + fingerprint: 'agenda:v1:work', + diagnostics: [], + items: [ + { + taskId: 'task-work', + displayId: 'c76d04cc', + subject: 'Создать каркас калькулятора', + assignee: 'bob', + kind: 'work', + priority: 'normal', + reason: 'owned_pending_task', + evidence: { + status: 'pending', + owner: 'bob', + }, + }, + ], + }, + shadow: { + reconciledBy: 'queue', + wouldNudge: true, + fingerprintChanged: false, + recovery: { + kind: 'proof_missing', + intentKey: 'proof-missing:message-1', + originalMessageId: 'message-1', + taskIds: ['task-work'], + }, + }, + }); + const payload = buildMemberWorkSyncNudgePayload(status); + const outboxInput = buildMemberWorkSyncOutboxEnsureInput({ + status, + nowIso: status.evaluatedAt, + hash: { + sha256Hex: (value) => `hash:${value.length}`, + }, + }); + + expect(payload.workSyncIntent).toBe('agenda_sync'); + expect(payload.workSyncIntentKey).toBe('proof-missing:message-1'); + expect(payload.text).toContain( + 'repairs OpenCode delivery proof for original messageId "message-1"' + ); + expect(payload.text).toContain('do not duplicate it'); + expect(outboxInput?.id).toBe( + 'member-work-sync:sable-ops:bob:proof-missing:message-1' + ); + }); }); diff --git a/test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts b/test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts index f99efd77..9185af3a 100644 --- a/test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts +++ b/test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts @@ -150,6 +150,47 @@ describe('MemberWorkSyncEventQueue', () => { await queue.stop(); }); + it('passes proof-missing recovery context into queued reconcile', 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', + runAfterMs: 0, + recovery: { + kind: 'proof_missing', + intentKey: 'proof-missing:message-1', + originalMessageId: 'message-1', + taskIds: ['task-a'], + }, + }); + + await vi.advanceTimersByTimeAsync(1); + + expect(reconciles).toHaveLength(1); + expect(reconciles[0]).toMatchObject({ + request: { teamName: 'team-a', memberName: 'bob' }, + context: { + reconciledBy: 'queue', + triggerReasons: ['proof_missing_recovery'], + recovery: { + kind: 'proof_missing', + intentKey: 'proof-missing:message-1', + originalMessageId: 'message-1', + taskIds: ['task-a'], + }, + }, + }); + 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({ diff --git a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts index e926db85..4a209371 100644 --- a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts +++ b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts @@ -311,6 +311,120 @@ async function forceRetryableOutboxDue(input: { } describe('createMemberWorkSyncFeature composition', () => { + it('schedules proof-missing recovery through the work-sync queue', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-a'; + const memberName = 'bob'; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName }], + })), + } as never, + taskReader: { getTasks: vi.fn(async () => []) } as never, + kanbanManager: { + getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {} })), + } as never, + membersMetaStore: { getMembers: vi.fn(async () => []) } as never, + }); + + try { + await expect( + feature.scheduleProofMissingRecovery({ + teamName, + memberName, + originalMessageId: 'message-1', + taskRefs: [{ taskId: 'task-1', displayId: '11111111', teamName }], + reason: 'OpenCode proof missing', + }) + ).resolves.toMatchObject({ + scheduled: true, + reason: 'scheduled', + intentKey: 'proof-missing:message-1', + }); + + expect(feature.getQueueDiagnostics()).toMatchObject({ + queued: 1, + queuedItems: [ + { + teamName, + memberName, + triggerReasons: ['proof_missing_recovery'], + }, + ], + }); + await expect(readInboxMessages({ teamsBasePath, teamName, memberName })).resolves.toEqual( + [] + ); + } finally { + await feature.dispose(); + } + }); + + it('coalesces proof-missing recovery when a recent matching outbox item exists', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-a'; + const memberName = 'bob'; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName }], + })), + } as never, + taskReader: { getTasks: vi.fn(async () => []) } as never, + kanbanManager: { + getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {} })), + } as never, + membersMetaStore: { getMembers: vi.fn(async () => []) } as never, + }); + + try { + const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)); + await store.ensurePending({ + id: 'member-work-sync:team-a:bob:proof-missing:message-1', + teamName, + memberName, + agendaFingerprint: 'agenda:v1:test', + payloadHash: 'payload-hash', + payload: { + from: 'system', + to: memberName, + messageKind: 'member_work_sync_nudge', + source: 'member-work-sync', + actionMode: 'do', + workSyncIntent: 'agenda_sync', + workSyncIntentKey: 'proof-missing:message-1', + text: 'Recover proof', + taskRefs: [{ taskId: 'task-1', displayId: '11111111', teamName }], + }, + nowIso: new Date().toISOString(), + }); + + await expect( + feature.scheduleProofMissingRecovery({ + teamName, + memberName, + originalMessageId: 'message-1', + }) + ).resolves.toMatchObject({ + scheduled: false, + reason: 'coalesced_recent', + existingOutboxId: 'member-work-sync:team-a:bob:proof-missing:message-1', + }); + expect(feature.getQueueDiagnostics()).toMatchObject({ queued: 0 }); + } finally { + await feature.dispose(); + } + }); + it('dispatches a due nudge through the real outbox and inbox by default', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); diff --git a/test/features/member-work-sync/main/registerMemberWorkSyncIpc.test.ts b/test/features/member-work-sync/main/registerMemberWorkSyncIpc.test.ts index b112b892..0dc43112 100644 --- a/test/features/member-work-sync/main/registerMemberWorkSyncIpc.test.ts +++ b/test/features/member-work-sync/main/registerMemberWorkSyncIpc.test.ts @@ -135,6 +135,10 @@ function makeFeature(): MemberWorkSyncFeatureFacade { diagnostics: [], }, })), + scheduleProofMissingRecovery: vi.fn(async () => ({ + scheduled: true, + reason: 'scheduled' as const, + })), noteTeamChange: vi.fn(), enqueueStartupScan: vi.fn(), replayPendingReports: vi.fn(),