feat(member-work-sync): recover missing opencode proof

This commit is contained in:
777genius 2026-05-14 01:48:49 +03:00
parent 6aad8b9c2c
commit 39c52c3847
11 changed files with 437 additions and 3 deletions

View file

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

View file

@ -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: [

View file

@ -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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(),