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;
|
fingerprintChanged: boolean;
|
||||||
previousFingerprint?: string;
|
previousFingerprint?: string;
|
||||||
triggerReasons?: string[];
|
triggerReasons?: string[];
|
||||||
|
recovery?: {
|
||||||
|
kind: 'proof_missing';
|
||||||
|
intentKey: string;
|
||||||
|
originalMessageId: string;
|
||||||
|
taskIds: string[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemberWorkSyncStatus {
|
export interface MemberWorkSyncStatus {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,12 @@ import type { MemberWorkSyncAgendaSourceResult, MemberWorkSyncUseCaseDeps } from
|
||||||
export interface MemberWorkSyncReconcileContext {
|
export interface MemberWorkSyncReconcileContext {
|
||||||
reconciledBy?: 'request' | 'queue';
|
reconciledBy?: 'request' | 'queue';
|
||||||
triggerReasons?: string[];
|
triggerReasons?: string[];
|
||||||
|
recovery?: {
|
||||||
|
kind: 'proof_missing';
|
||||||
|
intentKey: string;
|
||||||
|
originalMessageId: string;
|
||||||
|
taskIds?: string[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function finalizeMemberWorkSyncAgenda(
|
export function finalizeMemberWorkSyncAgenda(
|
||||||
|
|
@ -107,6 +113,16 @@ export class MemberWorkSyncReconciler {
|
||||||
...(context.triggerReasons?.length
|
...(context.triggerReasons?.length
|
||||||
? { triggerReasons: [...new Set(context.triggerReasons)].sort() }
|
? { 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,
|
evaluatedAt: nowIso,
|
||||||
diagnostics: [
|
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 {
|
function buildReviewPickupNudgePayload(status: MemberWorkSyncStatus): MemberWorkSyncNudgePayload {
|
||||||
const taskRefs = buildTaskRefs(status);
|
const taskRefs = buildTaskRefs(status);
|
||||||
const preview = buildAgendaPreview(status);
|
const preview = buildAgendaPreview(status);
|
||||||
|
|
@ -148,9 +160,13 @@ export function buildMemberWorkSyncNudgePayload(
|
||||||
source: 'member-work-sync',
|
source: 'member-work-sync',
|
||||||
actionMode: 'do',
|
actionMode: 'do',
|
||||||
workSyncIntent: 'agenda_sync',
|
workSyncIntent: 'agenda_sync',
|
||||||
|
...(status.shadow?.recovery?.intentKey
|
||||||
|
? { workSyncIntentKey: status.shadow.recovery.intentKey }
|
||||||
|
: {}),
|
||||||
taskRefs,
|
taskRefs,
|
||||||
text: [
|
text: [
|
||||||
'Work sync check: you have current actionable work assigned.',
|
'Work sync check: you have current actionable work assigned.',
|
||||||
|
...buildProofMissingRecoveryText(status),
|
||||||
preview ? `Current agenda: ${preview}.` : '',
|
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.`,
|
`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
|
taskIds.length
|
||||||
|
|
@ -191,8 +207,7 @@ export function buildMemberWorkSyncOutboxEnsureInput(input: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = buildMemberWorkSyncNudgePayload(status);
|
const payload = buildMemberWorkSyncNudgePayload(status);
|
||||||
const intentKey =
|
const intentKey = payload.workSyncIntentKey;
|
||||||
payload.workSyncIntent === 'review_pickup' ? payload.workSyncIntentKey : undefined;
|
|
||||||
return {
|
return {
|
||||||
id: buildMemberWorkSyncNudgeId({
|
id: buildMemberWorkSyncNudgeId({
|
||||||
teamName: status.teamName,
|
teamName: status.teamName,
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
|
||||||
import type { TeamChangeEvent } from '@shared/types';
|
import type { TeamChangeEvent } from '@shared/types';
|
||||||
|
|
||||||
const STALE_STATUS_MAX_AGE_MS = 2 * 60_000;
|
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[] {
|
function getStatusStalenessDiagnostics(status: MemberWorkSyncStatus, nowMs: number): string[] {
|
||||||
const diagnostics: string[] = [];
|
const diagnostics: string[] = [];
|
||||||
|
|
@ -103,6 +104,9 @@ export interface MemberWorkSyncFeatureFacade {
|
||||||
refreshStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
|
refreshStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
|
||||||
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
|
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
|
||||||
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
|
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
|
||||||
|
scheduleProofMissingRecovery(
|
||||||
|
request: MemberWorkSyncProofMissingRecoveryScheduleRequest
|
||||||
|
): Promise<MemberWorkSyncProofMissingRecoveryScheduleResult>;
|
||||||
noteTeamChange(event: TeamChangeEvent): void;
|
noteTeamChange(event: TeamChangeEvent): void;
|
||||||
enqueueStartupScan(teamNames: string[]): Promise<void>;
|
enqueueStartupScan(teamNames: string[]): Promise<void>;
|
||||||
replayPendingReports(teamNames: string[]): Promise<MemberWorkSyncPendingReportReplaySummary>;
|
replayPendingReports(teamNames: string[]): Promise<MemberWorkSyncPendingReportReplaySummary>;
|
||||||
|
|
@ -118,6 +122,45 @@ export interface MemberWorkSyncFeatureFacade {
|
||||||
dispose(): Promise<void>;
|
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: {
|
export function createMemberWorkSyncFeature(deps: {
|
||||||
teamsBasePath: string;
|
teamsBasePath: string;
|
||||||
configReader: TeamConfigReader;
|
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 {
|
return {
|
||||||
getStatus: readStatusWithStaleRefresh,
|
getStatus: readStatusWithStaleRefresh,
|
||||||
refreshStatus: (request) => reconciler.execute(request, { reconciledBy: 'request' }),
|
refreshStatus: (request) => reconciler.execute(request, { reconciledBy: 'request' }),
|
||||||
getMetrics: (request) => metricsReader.execute(request),
|
getMetrics: (request) => metricsReader.execute(request),
|
||||||
report: (request) => reporter.execute(request),
|
report: (request) => reporter.execute(request),
|
||||||
|
scheduleProofMissingRecovery,
|
||||||
noteTeamChange: (event) => {
|
noteTeamChange: (event) => {
|
||||||
toolActivityBusySignal.noteTeamChange(event);
|
toolActivityBusySignal.noteTeamChange(event);
|
||||||
router.noteTeamChange(event);
|
router.noteTeamChange(event);
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ interface QueueItem {
|
||||||
maxRunAt: number;
|
maxRunAt: number;
|
||||||
triggerReasons: Set<MemberWorkSyncTriggerReason>;
|
triggerReasons: Set<MemberWorkSyncTriggerReason>;
|
||||||
triggerReasonCounts: Map<MemberWorkSyncTriggerReason, number>;
|
triggerReasonCounts: Map<MemberWorkSyncTriggerReason, number>;
|
||||||
|
recovery?: MemberWorkSyncReconcileContext['recovery'];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RunningItem {
|
interface RunningItem {
|
||||||
|
|
@ -69,6 +70,7 @@ interface RunningItem {
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
rerunRequested: boolean;
|
rerunRequested: boolean;
|
||||||
triggerReasons: Set<MemberWorkSyncTriggerReason>;
|
triggerReasons: Set<MemberWorkSyncTriggerReason>;
|
||||||
|
recovery?: MemberWorkSyncReconcileContext['recovery'];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TriggerTimingPolicy {
|
interface TriggerTimingPolicy {
|
||||||
|
|
@ -152,6 +154,7 @@ export class MemberWorkSyncEventQueue {
|
||||||
memberName: string;
|
memberName: string;
|
||||||
triggerReason: MemberWorkSyncTriggerReason;
|
triggerReason: MemberWorkSyncTriggerReason;
|
||||||
runAfterMs?: number;
|
runAfterMs?: number;
|
||||||
|
recovery?: MemberWorkSyncReconcileContext['recovery'];
|
||||||
}): void {
|
}): void {
|
||||||
if (this.stopped) {
|
if (this.stopped) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -172,6 +175,9 @@ export class MemberWorkSyncEventQueue {
|
||||||
if (running) {
|
if (running) {
|
||||||
running.rerunRequested = true;
|
running.rerunRequested = true;
|
||||||
running.triggerReasons.add(input.triggerReason);
|
running.triggerReasons.add(input.triggerReason);
|
||||||
|
if (input.recovery) {
|
||||||
|
running.recovery = input.recovery;
|
||||||
|
}
|
||||||
this.counters.coalesced += 1;
|
this.counters.coalesced += 1;
|
||||||
this.appendAudit({
|
this.appendAudit({
|
||||||
teamName,
|
teamName,
|
||||||
|
|
@ -186,6 +192,9 @@ export class MemberWorkSyncEventQueue {
|
||||||
const existing = this.items.get(key);
|
const existing = this.items.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.triggerReasons.add(input.triggerReason);
|
existing.triggerReasons.add(input.triggerReason);
|
||||||
|
if (input.recovery) {
|
||||||
|
existing.recovery = input.recovery;
|
||||||
|
}
|
||||||
existing.lastQueuedAt = now;
|
existing.lastQueuedAt = now;
|
||||||
existing.maxRunAt = Math.max(
|
existing.maxRunAt = Math.max(
|
||||||
existing.maxRunAt,
|
existing.maxRunAt,
|
||||||
|
|
@ -221,6 +230,7 @@ export class MemberWorkSyncEventQueue {
|
||||||
maxRunAt: now + timing.maxCoalesceWaitMs,
|
maxRunAt: now + timing.maxCoalesceWaitMs,
|
||||||
triggerReasons: new Set([input.triggerReason]),
|
triggerReasons: new Set([input.triggerReason]),
|
||||||
triggerReasonCounts: new Map([[input.triggerReason, 1]]),
|
triggerReasonCounts: new Map([[input.triggerReason, 1]]),
|
||||||
|
...(input.recovery ? { recovery: input.recovery } : {}),
|
||||||
});
|
});
|
||||||
this.counters.enqueued += 1;
|
this.counters.enqueued += 1;
|
||||||
this.appendAudit({
|
this.appendAudit({
|
||||||
|
|
@ -352,6 +362,7 @@ export class MemberWorkSyncEventQueue {
|
||||||
startedAt: this.now(),
|
startedAt: this.now(),
|
||||||
rerunRequested: false,
|
rerunRequested: false,
|
||||||
triggerReasons: new Set(item.triggerReasons),
|
triggerReasons: new Set(item.triggerReasons),
|
||||||
|
...(item.recovery ? { recovery: item.recovery } : {}),
|
||||||
};
|
};
|
||||||
this.running.set(key, running);
|
this.running.set(key, running);
|
||||||
|
|
||||||
|
|
@ -378,6 +389,7 @@ export class MemberWorkSyncEventQueue {
|
||||||
|
|
||||||
private enqueueFollowUp(item: QueueItem, running: RunningItem): void {
|
private enqueueFollowUp(item: QueueItem, running: RunningItem): void {
|
||||||
const reasons = [...running.triggerReasons].sort();
|
const reasons = [...running.triggerReasons].sort();
|
||||||
|
const recovery = running.recovery ?? item.recovery;
|
||||||
const primaryReason =
|
const primaryReason =
|
||||||
reasons.find((reason) => reason === 'manual_refresh') ??
|
reasons.find((reason) => reason === 'manual_refresh') ??
|
||||||
reasons.find((reason) => reason === 'turn_settled' || reason === 'tool_finished') ??
|
reasons.find((reason) => reason === 'turn_settled' || reason === 'tool_finished') ??
|
||||||
|
|
@ -388,6 +400,7 @@ export class MemberWorkSyncEventQueue {
|
||||||
memberName: item.memberName,
|
memberName: item.memberName,
|
||||||
triggerReason: primaryReason,
|
triggerReason: primaryReason,
|
||||||
runAfterMs: Math.min(this.resolveTimingPolicy(primaryReason).runAfterMs, 5_000),
|
runAfterMs: Math.min(this.resolveTimingPolicy(primaryReason).runAfterMs, 5_000),
|
||||||
|
...(recovery ? { recovery } : {}),
|
||||||
});
|
});
|
||||||
const queued = this.items.get(keyOf(item.teamName, item.memberName));
|
const queued = this.items.get(keyOf(item.teamName, item.memberName));
|
||||||
if (!queued) {
|
if (!queued) {
|
||||||
|
|
@ -414,11 +427,13 @@ export class MemberWorkSyncEventQueue {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recovery = running.recovery ?? item.recovery;
|
||||||
await this.deps.reconcile(
|
await this.deps.reconcile(
|
||||||
{ teamName: item.teamName, memberName: item.memberName },
|
{ teamName: item.teamName, memberName: item.memberName },
|
||||||
{
|
{
|
||||||
reconciledBy: 'queue',
|
reconciledBy: 'queue',
|
||||||
triggerReasons: [...running.triggerReasons].sort(),
|
triggerReasons: [...running.triggerReasons].sort(),
|
||||||
|
...(recovery ? { recovery } : {}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
this.counters.reconciled += 1;
|
this.counters.reconciled += 1;
|
||||||
|
|
|
||||||
|
|
@ -1830,6 +1830,11 @@ async function initializeServices(): Promise<void> {
|
||||||
? memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment(input)
|
? memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment(input)
|
||||||
: Promise.resolve(null)
|
: Promise.resolve(null)
|
||||||
);
|
);
|
||||||
|
teamProvisioningService.setMemberWorkSyncProofMissingRecoveryScheduler((input) =>
|
||||||
|
memberWorkSyncFeature
|
||||||
|
? memberWorkSyncFeature.scheduleProofMissingRecovery(input)
|
||||||
|
: Promise.resolve({ scheduled: false, reason: 'invalid' })
|
||||||
|
);
|
||||||
scheduleStartupTask(() => {
|
scheduleStartupTask(() => {
|
||||||
void teamDataService
|
void teamDataService
|
||||||
.listTeams()
|
.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 {
|
function normalizeSameTeamText(text: string): string {
|
||||||
return text.trim().replace(/\r\n/g, '\n');
|
return text.trim().replace(/\r\n/g, '\n');
|
||||||
}
|
}
|
||||||
|
|
@ -5643,6 +5651,8 @@ export class TeamProvisioningService {
|
||||||
private memberRuntimeAdvisoryInvalidator:
|
private memberRuntimeAdvisoryInvalidator:
|
||||||
| ((teamName: string, memberName: string) => void)
|
| ((teamName: string, memberName: string) => void)
|
||||||
| null = null;
|
| null = null;
|
||||||
|
private memberWorkSyncProofMissingRecoveryScheduler: MemberWorkSyncProofMissingRecoveryScheduler | null =
|
||||||
|
null;
|
||||||
private readonly memberLogsFinder: TeamMemberLogsFinder;
|
private readonly memberLogsFinder: TeamMemberLogsFinder;
|
||||||
private readonly transcriptProjectResolver: TeamTranscriptProjectResolver;
|
private readonly transcriptProjectResolver: TeamTranscriptProjectResolver;
|
||||||
private readonly taskActivityIntervalService = new TeamTaskActivityIntervalService();
|
private readonly taskActivityIntervalService = new TeamTaskActivityIntervalService();
|
||||||
|
|
@ -6002,6 +6012,12 @@ export class TeamProvisioningService {
|
||||||
this.memberRuntimeAdvisoryInvalidator = invalidator;
|
this.memberRuntimeAdvisoryInvalidator = invalidator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMemberWorkSyncProofMissingRecoveryScheduler(
|
||||||
|
scheduler: MemberWorkSyncProofMissingRecoveryScheduler | null
|
||||||
|
): void {
|
||||||
|
this.memberWorkSyncProofMissingRecoveryScheduler = scheduler;
|
||||||
|
}
|
||||||
|
|
||||||
setCrossTeamSender(
|
setCrossTeamSender(
|
||||||
sender:
|
sender:
|
||||||
| ((request: {
|
| ((request: {
|
||||||
|
|
@ -8827,6 +8843,7 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emitOpenCodeRuntimeDeliveryAdvisoryEvent(latestRecord, decision);
|
this.emitOpenCodeRuntimeDeliveryAdvisoryEvent(latestRecord, decision);
|
||||||
|
await this.scheduleOpenCodeProofMissingWorkSyncRecovery(latestRecord, decision);
|
||||||
if (decision.severity !== 'error') {
|
if (decision.severity !== 'error') {
|
||||||
return;
|
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(
|
private emitOpenCodeRuntimeDeliveryAdvisoryEvent(
|
||||||
record: OpenCodePromptDeliveryLedgerRecord,
|
record: OpenCodePromptDeliveryLedgerRecord,
|
||||||
decision?: OpenCodeRuntimeDeliveryAdvisoryDecision
|
decision?: OpenCodeRuntimeDeliveryAdvisoryDecision
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
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';
|
import type { MemberWorkSyncStatus } from '@features/member-work-sync/contracts';
|
||||||
|
|
||||||
function makeStatus(
|
function makeStatus(
|
||||||
|
|
@ -86,4 +89,61 @@ describe('MemberWorkSyncNudge', () => {
|
||||||
|
|
||||||
expect(payload.text).not.toContain('task_set_clarification value "user"');
|
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();
|
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 () => {
|
it('does not let a later quiet-window event delay a queued manual refresh', async () => {
|
||||||
const reconciles: unknown[] = [];
|
const reconciles: unknown[] = [];
|
||||||
const queue = new MemberWorkSyncEventQueue({
|
const queue = new MemberWorkSyncEventQueue({
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,120 @@ async function forceRetryableOutboxDue(input: {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('createMemberWorkSyncFeature composition', () => {
|
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 () => {
|
it('dispatches a due nudge through the real outbox and inbox by default', async () => {
|
||||||
const claudeRoot = makeTempRoot();
|
const claudeRoot = makeTempRoot();
|
||||||
setClaudeBasePathOverride(claudeRoot);
|
setClaudeBasePathOverride(claudeRoot);
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,10 @@ function makeFeature(): MemberWorkSyncFeatureFacade {
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
scheduleProofMissingRecovery: vi.fn(async () => ({
|
||||||
|
scheduled: true,
|
||||||
|
reason: 'scheduled' as const,
|
||||||
|
})),
|
||||||
noteTeamChange: vi.fn(),
|
noteTeamChange: vi.fn(),
|
||||||
enqueueStartupScan: vi.fn(),
|
enqueueStartupScan: vi.fn(),
|
||||||
replayPendingReports: vi.fn(),
|
replayPendingReports: vi.fn(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue