feat(member-work-sync): recover missing opencode proof
This commit is contained in:
parent
6aad8b9c2c
commit
39c52c3847
11 changed files with 437 additions and 3 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<MemberWorkSyncStatus>;
|
||||
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
|
||||
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
|
||||
scheduleProofMissingRecovery(
|
||||
request: MemberWorkSyncProofMissingRecoveryScheduleRequest
|
||||
): Promise<MemberWorkSyncProofMissingRecoveryScheduleResult>;
|
||||
noteTeamChange(event: TeamChangeEvent): void;
|
||||
enqueueStartupScan(teamNames: string[]): Promise<void>;
|
||||
replayPendingReports(teamNames: string[]): Promise<MemberWorkSyncPendingReportReplaySummary>;
|
||||
|
|
@ -118,6 +122,45 @@ export interface MemberWorkSyncFeatureFacade {
|
|||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
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<string>();
|
||||
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<MemberWorkSyncProofMissingRecoveryScheduleResult> => {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ interface QueueItem {
|
|||
maxRunAt: number;
|
||||
triggerReasons: Set<MemberWorkSyncTriggerReason>;
|
||||
triggerReasonCounts: Map<MemberWorkSyncTriggerReason, number>;
|
||||
recovery?: MemberWorkSyncReconcileContext['recovery'];
|
||||
}
|
||||
|
||||
interface RunningItem {
|
||||
|
|
@ -69,6 +70,7 @@ interface RunningItem {
|
|||
startedAt: number;
|
||||
rerunRequested: boolean;
|
||||
triggerReasons: Set<MemberWorkSyncTriggerReason>;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -1830,6 +1830,11 @@ async function initializeServices(): Promise<void> {
|
|||
? memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment(input)
|
||||
: Promise.resolve(null)
|
||||
);
|
||||
teamProvisioningService.setMemberWorkSyncProofMissingRecoveryScheduler((input) =>
|
||||
memberWorkSyncFeature
|
||||
? memberWorkSyncFeature.scheduleProofMissingRecovery(input)
|
||||
: Promise.resolve({ scheduled: false, reason: 'invalid' })
|
||||
);
|
||||
scheduleStartupTask(() => {
|
||||
void teamDataService
|
||||
.listTeams()
|
||||
|
|
|
|||
|
|
@ -5484,6 +5484,14 @@ interface OpenCodeMemberInboxRelayOptions {
|
|||
};
|
||||
}
|
||||
|
||||
type MemberWorkSyncProofMissingRecoveryScheduler = (input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
originalMessageId: string;
|
||||
taskRefs?: TaskRef[];
|
||||
reason?: string;
|
||||
}) => Promise<unknown> | 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<void> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue