feat(member-work-sync): add guarded nudge dispatcher
This commit is contained in:
parent
3552a4ba61
commit
23714f5ca8
8 changed files with 386 additions and 5 deletions
|
|
@ -36,6 +36,7 @@ Current implementation note:
|
||||||
- Phase 1.5 exposes a machine-readable `phase2Readiness` assessment from shadow metrics. It can say `collecting_shadow_data`, `blocked`, or `shadow_ready`; it still does not dispatch nudges.
|
- Phase 1.5 exposes a machine-readable `phase2Readiness` assessment from shadow metrics. It can say `collecting_shadow_data`, `blocked`, or `shadow_ready`; it still does not dispatch nudges.
|
||||||
- Phase 2 storage foundation is implemented as an inert durable outbox: idempotency key, payload hash conflict checks, claim generation fencing, retry/terminal states. It is not wired to dispatch inbox nudges yet.
|
- Phase 2 storage foundation is implemented as an inert durable outbox: idempotency key, payload hash conflict checks, claim generation fencing, retry/terminal states. It is not wired to dispatch inbox nudges yet.
|
||||||
- Reconciler can plan a Phase 2 outbox item only when `phase2Readiness=shadow_ready`; otherwise it records normal shadow status and does nothing. This preserves the anti-spam gate before any dispatcher exists.
|
- Reconciler can plan a Phase 2 outbox item only when `phase2Readiness=shadow_ready`; otherwise it records normal shadow status and does nothing. This preserves the anti-spam gate before any dispatcher exists.
|
||||||
|
- Dispatcher use case exists behind explicit facade invocation. It claims due outbox rows, revalidates active team/status/current fingerprint/readiness/busy/watchdog cooldown, then writes one idempotent inbox nudge through a narrow port.
|
||||||
- Phase 2 must not start until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low.
|
- Phase 2 must not start until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low.
|
||||||
|
|
||||||
Patterns used:
|
Patterns used:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
import type { MemberWorkSyncOutboxItem } from '../../contracts';
|
||||||
|
import type { MemberWorkSyncUseCaseDeps } from './ports';
|
||||||
|
|
||||||
|
export interface MemberWorkSyncNudgeDispatchSummary {
|
||||||
|
claimed: number;
|
||||||
|
delivered: number;
|
||||||
|
superseded: number;
|
||||||
|
retryable: number;
|
||||||
|
terminal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberWorkSyncNudgeDispatchOptions {
|
||||||
|
claimedBy: string;
|
||||||
|
teamNames: string[];
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptySummary(): MemberWorkSyncNudgeDispatchSummary {
|
||||||
|
return { claimed: 0, delivered: 0, superseded: 0, retryable: 0, terminal: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMinutes(iso: string, minutes: number): string {
|
||||||
|
return new Date(Date.parse(iso) + minutes * 60_000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MemberWorkSyncNudgeDispatcher {
|
||||||
|
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {}
|
||||||
|
|
||||||
|
async dispatchDue(options: MemberWorkSyncNudgeDispatchOptions): Promise<MemberWorkSyncNudgeDispatchSummary> {
|
||||||
|
const outbox = this.deps.outboxStore;
|
||||||
|
const inbox = this.deps.inboxNudge;
|
||||||
|
if (!outbox || !inbox) {
|
||||||
|
return emptySummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowIso = this.deps.clock.now().toISOString();
|
||||||
|
const summary = emptySummary();
|
||||||
|
for (const teamName of [...new Set(options.teamNames.map((name) => name.trim()).filter(Boolean))]) {
|
||||||
|
const claimed = await outbox.claimDue({
|
||||||
|
teamName,
|
||||||
|
claimedBy: options.claimedBy,
|
||||||
|
nowIso,
|
||||||
|
limit: options.limit ?? 10,
|
||||||
|
});
|
||||||
|
summary.claimed += claimed.length;
|
||||||
|
for (const item of claimed) {
|
||||||
|
const result = await this.dispatchItem(item, nowIso);
|
||||||
|
summary[result] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async dispatchItem(
|
||||||
|
item: MemberWorkSyncOutboxItem,
|
||||||
|
nowIso: string
|
||||||
|
): Promise<keyof Omit<MemberWorkSyncNudgeDispatchSummary, 'claimed'>> {
|
||||||
|
const outbox = this.deps.outboxStore;
|
||||||
|
const inbox = this.deps.inboxNudge;
|
||||||
|
if (!outbox || !inbox) {
|
||||||
|
return 'terminal';
|
||||||
|
}
|
||||||
|
|
||||||
|
const revalidation = await this.revalidate(item, nowIso);
|
||||||
|
if (!revalidation.ok) {
|
||||||
|
if (revalidation.retryable) {
|
||||||
|
await outbox.markFailed({
|
||||||
|
teamName: item.teamName,
|
||||||
|
id: item.id,
|
||||||
|
attemptGeneration: item.attemptGeneration,
|
||||||
|
error: revalidation.reason,
|
||||||
|
retryable: true,
|
||||||
|
nowIso,
|
||||||
|
nextAttemptAt: revalidation.nextAttemptAt ?? addMinutes(nowIso, 10),
|
||||||
|
});
|
||||||
|
return 'retryable';
|
||||||
|
}
|
||||||
|
await outbox.markSuperseded({
|
||||||
|
teamName: item.teamName,
|
||||||
|
id: item.id,
|
||||||
|
reason: revalidation.reason,
|
||||||
|
nowIso,
|
||||||
|
});
|
||||||
|
return 'superseded';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inserted = await inbox.insertIfAbsent({
|
||||||
|
teamName: item.teamName,
|
||||||
|
memberName: item.memberName,
|
||||||
|
messageId: item.id,
|
||||||
|
payloadHash: item.payloadHash,
|
||||||
|
payload: item.payload,
|
||||||
|
timestamp: nowIso,
|
||||||
|
});
|
||||||
|
if (inserted.conflict) {
|
||||||
|
await outbox.markFailed({
|
||||||
|
teamName: item.teamName,
|
||||||
|
id: item.id,
|
||||||
|
attemptGeneration: item.attemptGeneration,
|
||||||
|
error: 'inbox_payload_conflict',
|
||||||
|
retryable: false,
|
||||||
|
nowIso,
|
||||||
|
});
|
||||||
|
return 'terminal';
|
||||||
|
}
|
||||||
|
await outbox.markDelivered({
|
||||||
|
teamName: item.teamName,
|
||||||
|
id: item.id,
|
||||||
|
attemptGeneration: item.attemptGeneration,
|
||||||
|
deliveredMessageId: inserted.messageId,
|
||||||
|
nowIso,
|
||||||
|
});
|
||||||
|
return 'delivered';
|
||||||
|
} catch (error) {
|
||||||
|
await outbox.markFailed({
|
||||||
|
teamName: item.teamName,
|
||||||
|
id: item.id,
|
||||||
|
attemptGeneration: item.attemptGeneration,
|
||||||
|
error: String(error),
|
||||||
|
retryable: true,
|
||||||
|
nowIso,
|
||||||
|
nextAttemptAt: addMinutes(nowIso, 10),
|
||||||
|
});
|
||||||
|
return 'retryable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async revalidate(
|
||||||
|
item: MemberWorkSyncOutboxItem,
|
||||||
|
nowIso: string
|
||||||
|
): Promise<
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string }
|
||||||
|
> {
|
||||||
|
if (this.deps.lifecycle && !(await this.deps.lifecycle.isTeamActive(item.teamName))) {
|
||||||
|
return { ok: false, reason: 'team_inactive', retryable: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await this.deps.statusStore.read({
|
||||||
|
teamName: item.teamName,
|
||||||
|
memberName: item.memberName,
|
||||||
|
});
|
||||||
|
if (!status) {
|
||||||
|
return { ok: false, reason: 'status_missing', retryable: false };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
status.state !== 'needs_sync' ||
|
||||||
|
status.shadow?.wouldNudge !== true ||
|
||||||
|
status.agenda.fingerprint !== item.agendaFingerprint
|
||||||
|
) {
|
||||||
|
return { ok: false, reason: 'status_no_longer_matches_outbox', retryable: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.deps.statusStore.readTeamMetrics) {
|
||||||
|
return { ok: false, reason: 'metrics_unavailable', retryable: true };
|
||||||
|
}
|
||||||
|
const metrics = await this.deps.statusStore.readTeamMetrics(item.teamName);
|
||||||
|
if (metrics.phase2Readiness.state !== 'shadow_ready') {
|
||||||
|
return { ok: false, reason: 'phase2_not_ready', retryable: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const busy = await this.deps.busySignal?.isBusy({
|
||||||
|
teamName: item.teamName,
|
||||||
|
memberName: item.memberName,
|
||||||
|
nowIso,
|
||||||
|
});
|
||||||
|
if (busy?.busy) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: `member_busy:${busy.reason ?? 'unknown'}`,
|
||||||
|
retryable: true,
|
||||||
|
nextAttemptAt: busy.retryAfterIso,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskIds = item.payload.taskRefs.map((taskRef) => taskRef.taskId);
|
||||||
|
if (
|
||||||
|
this.deps.watchdogCooldown &&
|
||||||
|
(await this.deps.watchdogCooldown.hasRecentNudge({
|
||||||
|
teamName: item.teamName,
|
||||||
|
memberName: item.memberName,
|
||||||
|
taskIds,
|
||||||
|
nowIso,
|
||||||
|
}))
|
||||||
|
) {
|
||||||
|
return { ok: false, reason: 'watchdog_cooldown_active', retryable: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export * from './MemberWorkSyncDiagnosticsReader';
|
export * from './MemberWorkSyncDiagnosticsReader';
|
||||||
export * from './MemberWorkSyncMetricsReader';
|
export * from './MemberWorkSyncMetricsReader';
|
||||||
|
export * from './MemberWorkSyncNudgeDispatcher';
|
||||||
export * from './MemberWorkSyncNudgeOutboxPlanner';
|
export * from './MemberWorkSyncNudgeOutboxPlanner';
|
||||||
export * from './MemberWorkSyncPendingReportIntentReplayer';
|
export * from './MemberWorkSyncPendingReportIntentReplayer';
|
||||||
export * from './MemberWorkSyncReconciler';
|
export * from './MemberWorkSyncReconciler';
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,34 @@ export interface MemberWorkSyncOutboxStorePort {
|
||||||
markFailed(input: MemberWorkSyncOutboxMarkFailedInput): Promise<void>;
|
markFailed(input: MemberWorkSyncOutboxMarkFailedInput): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MemberWorkSyncInboxNudgePort {
|
||||||
|
insertIfAbsent(input: {
|
||||||
|
teamName: string;
|
||||||
|
memberName: string;
|
||||||
|
messageId: string;
|
||||||
|
payloadHash: string;
|
||||||
|
payload: MemberWorkSyncOutboxItem['payload'];
|
||||||
|
timestamp: string;
|
||||||
|
}): Promise<{ inserted: boolean; messageId: string; conflict?: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberWorkSyncWatchdogCooldownPort {
|
||||||
|
hasRecentNudge(input: {
|
||||||
|
teamName: string;
|
||||||
|
memberName: string;
|
||||||
|
taskIds: string[];
|
||||||
|
nowIso: string;
|
||||||
|
}): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberWorkSyncBusySignalPort {
|
||||||
|
isBusy(input: {
|
||||||
|
teamName: string;
|
||||||
|
memberName: string;
|
||||||
|
nowIso: string;
|
||||||
|
}): Promise<{ busy: boolean; reason?: string; retryAfterIso?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MemberWorkSyncUseCaseDeps {
|
export interface MemberWorkSyncUseCaseDeps {
|
||||||
clock: MemberWorkSyncClockPort;
|
clock: MemberWorkSyncClockPort;
|
||||||
hash: MemberWorkSyncHashPort;
|
hash: MemberWorkSyncHashPort;
|
||||||
|
|
@ -109,6 +137,9 @@ export interface MemberWorkSyncUseCaseDeps {
|
||||||
statusStore: MemberWorkSyncStatusStorePort;
|
statusStore: MemberWorkSyncStatusStorePort;
|
||||||
reportStore?: MemberWorkSyncReportStorePort;
|
reportStore?: MemberWorkSyncReportStorePort;
|
||||||
outboxStore?: MemberWorkSyncOutboxStorePort;
|
outboxStore?: MemberWorkSyncOutboxStorePort;
|
||||||
|
inboxNudge?: MemberWorkSyncInboxNudgePort;
|
||||||
|
watchdogCooldown?: MemberWorkSyncWatchdogCooldownPort;
|
||||||
|
busySignal?: MemberWorkSyncBusySignalPort;
|
||||||
reportToken?: MemberWorkSyncReportTokenPort;
|
reportToken?: MemberWorkSyncReportTokenPort;
|
||||||
lifecycle?: MemberWorkSyncLifecyclePort;
|
lifecycle?: MemberWorkSyncLifecyclePort;
|
||||||
logger?: MemberWorkSyncLoggerPort;
|
logger?: MemberWorkSyncLoggerPort;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { TeamInboxReader } from '@main/services/team/TeamInboxReader';
|
||||||
|
import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
|
||||||
|
import type { MemberWorkSyncInboxNudgePort } from '../../../core/application';
|
||||||
|
|
||||||
|
export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudgePort {
|
||||||
|
constructor(
|
||||||
|
private readonly inboxReader: Pick<TeamInboxReader, 'getMessagesFor'> = new TeamInboxReader(),
|
||||||
|
private readonly inboxWriter: Pick<TeamInboxWriter, 'sendMessage'> = new TeamInboxWriter()
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async insertIfAbsent(input: Parameters<MemberWorkSyncInboxNudgePort['insertIfAbsent']>[0]) {
|
||||||
|
const existing = await this.inboxReader.getMessagesFor(input.teamName, input.memberName);
|
||||||
|
if (existing.some((message) => message.messageId === input.messageId)) {
|
||||||
|
return { inserted: false, messageId: input.messageId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.inboxWriter.sendMessage(input.teamName, {
|
||||||
|
member: input.memberName,
|
||||||
|
from: input.payload.from,
|
||||||
|
to: input.payload.to,
|
||||||
|
messageId: input.messageId,
|
||||||
|
timestamp: input.timestamp,
|
||||||
|
text: input.payload.text,
|
||||||
|
taskRefs: input.payload.taskRefs,
|
||||||
|
actionMode: input.payload.actionMode,
|
||||||
|
summary: 'Work sync check',
|
||||||
|
source: 'system_notification',
|
||||||
|
messageKind: input.payload.messageKind,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
inserted: true,
|
||||||
|
messageId: result.messageId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,8 @@ import type {
|
||||||
import {
|
import {
|
||||||
MemberWorkSyncDiagnosticsReader,
|
MemberWorkSyncDiagnosticsReader,
|
||||||
MemberWorkSyncMetricsReader,
|
MemberWorkSyncMetricsReader,
|
||||||
|
MemberWorkSyncNudgeDispatcher,
|
||||||
|
type MemberWorkSyncNudgeDispatchSummary,
|
||||||
MemberWorkSyncPendingReportIntentReplayer,
|
MemberWorkSyncPendingReportIntentReplayer,
|
||||||
type MemberWorkSyncPendingReportReplaySummary,
|
type MemberWorkSyncPendingReportReplaySummary,
|
||||||
MemberWorkSyncReconciler,
|
MemberWorkSyncReconciler,
|
||||||
|
|
@ -16,6 +18,7 @@ import {
|
||||||
type MemberWorkSyncReconcileContext,
|
type MemberWorkSyncReconcileContext,
|
||||||
} from '../../core/application';
|
} from '../../core/application';
|
||||||
import { MemberWorkSyncTeamChangeRouter } from '../adapters/input/MemberWorkSyncTeamChangeRouter';
|
import { MemberWorkSyncTeamChangeRouter } from '../adapters/input/MemberWorkSyncTeamChangeRouter';
|
||||||
|
import { TeamInboxMemberWorkSyncNudgeSink } from '../adapters/output/TeamInboxMemberWorkSyncNudgeSink';
|
||||||
import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource';
|
import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource';
|
||||||
import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter';
|
import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter';
|
||||||
import {
|
import {
|
||||||
|
|
@ -41,6 +44,7 @@ export interface MemberWorkSyncFeatureFacade {
|
||||||
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>;
|
||||||
|
dispatchDueNudges(teamNames: string[]): Promise<MemberWorkSyncNudgeDispatchSummary>;
|
||||||
getQueueDiagnostics(): MemberWorkSyncQueueDiagnostics;
|
getQueueDiagnostics(): MemberWorkSyncQueueDiagnostics;
|
||||||
dispose(): Promise<void>;
|
dispose(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
@ -67,6 +71,7 @@ export function createMemberWorkSyncFeature(deps: {
|
||||||
const storePaths = new MemberWorkSyncStorePaths(deps.teamsBasePath);
|
const storePaths = new MemberWorkSyncStorePaths(deps.teamsBasePath);
|
||||||
const store = new JsonMemberWorkSyncStore(storePaths);
|
const store = new JsonMemberWorkSyncStore(storePaths);
|
||||||
const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths);
|
const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths);
|
||||||
|
const inboxNudge = new TeamInboxMemberWorkSyncNudgeSink();
|
||||||
const useCaseDeps = {
|
const useCaseDeps = {
|
||||||
clock,
|
clock,
|
||||||
hash,
|
hash,
|
||||||
|
|
@ -74,6 +79,7 @@ export function createMemberWorkSyncFeature(deps: {
|
||||||
statusStore: store,
|
statusStore: store,
|
||||||
reportStore: store,
|
reportStore: store,
|
||||||
outboxStore: store,
|
outboxStore: store,
|
||||||
|
inboxNudge,
|
||||||
reportToken,
|
reportToken,
|
||||||
...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}),
|
...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}),
|
||||||
logger: deps.logger,
|
logger: deps.logger,
|
||||||
|
|
@ -83,6 +89,7 @@ export function createMemberWorkSyncFeature(deps: {
|
||||||
const reporter = new MemberWorkSyncReporter(useCaseDeps);
|
const reporter = new MemberWorkSyncReporter(useCaseDeps);
|
||||||
const reconciler = new MemberWorkSyncReconciler(useCaseDeps);
|
const reconciler = new MemberWorkSyncReconciler(useCaseDeps);
|
||||||
const pendingReportReplayer = new MemberWorkSyncPendingReportIntentReplayer(useCaseDeps);
|
const pendingReportReplayer = new MemberWorkSyncPendingReportIntentReplayer(useCaseDeps);
|
||||||
|
const nudgeDispatcher = new MemberWorkSyncNudgeDispatcher(useCaseDeps);
|
||||||
const queue = new MemberWorkSyncEventQueue({
|
const queue = new MemberWorkSyncEventQueue({
|
||||||
reconcile: async (request, context: MemberWorkSyncReconcileContext) => {
|
reconcile: async (request, context: MemberWorkSyncReconcileContext) => {
|
||||||
await reconciler.execute(request, context);
|
await reconciler.execute(request, context);
|
||||||
|
|
@ -116,6 +123,8 @@ export function createMemberWorkSyncFeature(deps: {
|
||||||
{ processed: 0, accepted: 0, rejected: 0, superseded: 0 }
|
{ processed: 0, accepted: 0, rejected: 0, superseded: 0 }
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
dispatchDueNudges: (teamNames) =>
|
||||||
|
nudgeDispatcher.dispatchDue({ teamNames, claimedBy: `member-work-sync:${process.pid}` }),
|
||||||
getQueueDiagnostics: () => queue.getDiagnostics(),
|
getQueueDiagnostics: () => queue.getDiagnostics(),
|
||||||
dispose: () => queue.stop(),
|
dispose: () => queue.stop(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -413,7 +413,8 @@ export type InboxMessageKind =
|
||||||
| 'default'
|
| 'default'
|
||||||
| 'slash_command'
|
| 'slash_command'
|
||||||
| 'slash_command_result'
|
| 'slash_command_result'
|
||||||
| 'task_comment_notification';
|
| 'task_comment_notification'
|
||||||
|
| 'member_work_sync_nudge';
|
||||||
|
|
||||||
export interface SlashCommandMeta {
|
export interface SlashCommandMeta {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MemberWorkSyncDiagnosticsReader,
|
MemberWorkSyncDiagnosticsReader,
|
||||||
|
MemberWorkSyncNudgeDispatcher,
|
||||||
MemberWorkSyncPendingReportIntentReplayer,
|
MemberWorkSyncPendingReportIntentReplayer,
|
||||||
MemberWorkSyncReporter,
|
MemberWorkSyncReporter,
|
||||||
type MemberWorkSyncAgendaSourceResult,
|
type MemberWorkSyncAgendaSourceResult,
|
||||||
|
type MemberWorkSyncInboxNudgePort,
|
||||||
type MemberWorkSyncOutboxStorePort,
|
type MemberWorkSyncOutboxStorePort,
|
||||||
type MemberWorkSyncStatusStorePort,
|
type MemberWorkSyncStatusStorePort,
|
||||||
type MemberWorkSyncUseCaseDeps,
|
type MemberWorkSyncUseCaseDeps,
|
||||||
|
|
@ -131,9 +133,14 @@ class InMemoryStatusStore implements MemberWorkSyncStatusStorePort {
|
||||||
|
|
||||||
class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort {
|
class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort {
|
||||||
readonly ensures: MemberWorkSyncOutboxEnsureInput[] = [];
|
readonly ensures: MemberWorkSyncOutboxEnsureInput[] = [];
|
||||||
|
readonly items = new Map<string, MemberWorkSyncOutboxItem>();
|
||||||
|
|
||||||
async ensurePending(input: MemberWorkSyncOutboxEnsureInput) {
|
async ensurePending(input: MemberWorkSyncOutboxEnsureInput) {
|
||||||
this.ensures.push(input);
|
this.ensures.push(input);
|
||||||
|
const current = this.items.get(input.id);
|
||||||
|
if (current) {
|
||||||
|
return { ok: true as const, outcome: 'existing' as const, item: current };
|
||||||
|
}
|
||||||
const item: MemberWorkSyncOutboxItem = {
|
const item: MemberWorkSyncOutboxItem = {
|
||||||
...input,
|
...input,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
|
|
@ -141,18 +148,59 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort {
|
||||||
createdAt: input.nowIso,
|
createdAt: input.nowIso,
|
||||||
updatedAt: input.nowIso,
|
updatedAt: input.nowIso,
|
||||||
};
|
};
|
||||||
|
this.items.set(input.id, item);
|
||||||
return { ok: true as const, outcome: 'created' as const, item };
|
return { ok: true as const, outcome: 'created' as const, item };
|
||||||
}
|
}
|
||||||
|
|
||||||
async claimDue(): Promise<MemberWorkSyncOutboxItem[]> {
|
async claimDue(): Promise<MemberWorkSyncOutboxItem[]> {
|
||||||
return [];
|
const due = [...this.items.values()].filter((item) => item.status === 'pending');
|
||||||
|
for (const item of due) {
|
||||||
|
this.items.set(item.id, {
|
||||||
|
...item,
|
||||||
|
status: 'claimed',
|
||||||
|
attemptGeneration: item.attemptGeneration + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return due.map((item) => this.items.get(item.id) as MemberWorkSyncOutboxItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
async markDelivered(_input: MemberWorkSyncOutboxMarkDeliveredInput): Promise<void> {}
|
async markDelivered(input: MemberWorkSyncOutboxMarkDeliveredInput): Promise<void> {
|
||||||
|
const current = this.items.get(input.id);
|
||||||
|
if (current?.attemptGeneration === input.attemptGeneration) {
|
||||||
|
this.items.set(input.id, {
|
||||||
|
...current,
|
||||||
|
status: 'delivered',
|
||||||
|
deliveredMessageId: input.deliveredMessageId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async markSuperseded(_input: MemberWorkSyncOutboxMarkSupersededInput): Promise<void> {}
|
async markSuperseded(input: MemberWorkSyncOutboxMarkSupersededInput): Promise<void> {
|
||||||
|
const current = this.items.get(input.id);
|
||||||
|
if (current) {
|
||||||
|
this.items.set(input.id, { ...current, status: 'superseded', lastError: input.reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async markFailed(_input: MemberWorkSyncOutboxMarkFailedInput): Promise<void> {}
|
async markFailed(input: MemberWorkSyncOutboxMarkFailedInput): Promise<void> {
|
||||||
|
const current = this.items.get(input.id);
|
||||||
|
if (current?.attemptGeneration === input.attemptGeneration) {
|
||||||
|
this.items.set(input.id, {
|
||||||
|
...current,
|
||||||
|
status: input.retryable ? 'failed_retryable' : 'failed_terminal',
|
||||||
|
lastError: input.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InMemoryInboxNudge implements MemberWorkSyncInboxNudgePort {
|
||||||
|
readonly inserted: Array<Parameters<MemberWorkSyncInboxNudgePort['insertIfAbsent']>[0]> = [];
|
||||||
|
|
||||||
|
async insertIfAbsent(input: Parameters<MemberWorkSyncInboxNudgePort['insertIfAbsent']>[0]) {
|
||||||
|
this.inserted.push(input);
|
||||||
|
return { inserted: true, messageId: input.messageId };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDeps(options?: {
|
function createDeps(options?: {
|
||||||
|
|
@ -162,6 +210,7 @@ function createDeps(options?: {
|
||||||
teamActive?: boolean;
|
teamActive?: boolean;
|
||||||
providerId?: 'opencode' | 'codex';
|
providerId?: 'opencode' | 'codex';
|
||||||
outboxStore?: MemberWorkSyncOutboxStorePort;
|
outboxStore?: MemberWorkSyncOutboxStorePort;
|
||||||
|
inboxNudge?: MemberWorkSyncInboxNudgePort;
|
||||||
}) {
|
}) {
|
||||||
const clock = new MutableClock();
|
const clock = new MutableClock();
|
||||||
const store = new InMemoryStatusStore();
|
const store = new InMemoryStatusStore();
|
||||||
|
|
@ -189,6 +238,7 @@ function createDeps(options?: {
|
||||||
statusStore: store,
|
statusStore: store,
|
||||||
reportStore: store,
|
reportStore: store,
|
||||||
...(options?.outboxStore ? { outboxStore: options.outboxStore } : {}),
|
...(options?.outboxStore ? { outboxStore: options.outboxStore } : {}),
|
||||||
|
...(options?.inboxNudge ? { inboxNudge: options.inboxNudge } : {}),
|
||||||
reportToken: {
|
reportToken: {
|
||||||
create: async (input) => ({
|
create: async (input) => ({
|
||||||
token: `token:${input.teamName}:${input.memberName}:${input.agendaFingerprint}`,
|
token: `token:${input.teamName}:${input.memberName}:${input.agendaFingerprint}`,
|
||||||
|
|
@ -395,6 +445,66 @@ describe('MemberWorkSync use cases', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('dispatches due nudges only after revalidating current status and readiness', async () => {
|
||||||
|
const outbox = new InMemoryOutboxStore();
|
||||||
|
const inbox = new InMemoryInboxNudge();
|
||||||
|
const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox });
|
||||||
|
store.phase2ReadinessState = 'shadow_ready';
|
||||||
|
|
||||||
|
const status = await new MemberWorkSyncDiagnosticsReader(deps).execute({
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
});
|
||||||
|
const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||||
|
teamNames: ['team-a'],
|
||||||
|
claimedBy: 'test-dispatcher',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summary).toMatchObject({ claimed: 1, delivered: 1, superseded: 0 });
|
||||||
|
expect(inbox.inserted).toHaveLength(1);
|
||||||
|
expect(inbox.inserted[0]).toMatchObject({
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
messageId: `member-work-sync:team-a:bob:${status.agenda.fingerprint}`,
|
||||||
|
});
|
||||||
|
expect(outbox.items.get(`member-work-sync:team-a:bob:${status.agenda.fingerprint}`)).toMatchObject({
|
||||||
|
status: 'delivered',
|
||||||
|
deliveredMessageId: `member-work-sync:team-a:bob:${status.agenda.fingerprint}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not dispatch stale outbox items after the member reports still working', async () => {
|
||||||
|
const outbox = new InMemoryOutboxStore();
|
||||||
|
const inbox = new InMemoryInboxNudge();
|
||||||
|
const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox });
|
||||||
|
store.phase2ReadinessState = 'shadow_ready';
|
||||||
|
|
||||||
|
const reader = new MemberWorkSyncDiagnosticsReader(deps);
|
||||||
|
const reporter = new MemberWorkSyncReporter(deps);
|
||||||
|
const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' });
|
||||||
|
await reporter.execute({
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
state: 'still_working',
|
||||||
|
agendaFingerprint: current.agenda.fingerprint,
|
||||||
|
reportToken: current.reportToken,
|
||||||
|
leaseTtlMs: 120_000,
|
||||||
|
source: 'test',
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||||
|
teamNames: ['team-a'],
|
||||||
|
claimedBy: 'test-dispatcher',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summary).toMatchObject({ claimed: 1, delivered: 0, superseded: 1 });
|
||||||
|
expect(inbox.inserted).toEqual([]);
|
||||||
|
expect(outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`)).toMatchObject({
|
||||||
|
status: 'superseded',
|
||||||
|
lastError: 'status_no_longer_matches_outbox',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects invalid report tokens without recording replayable intents', async () => {
|
it('rejects invalid report tokens without recording replayable intents', async () => {
|
||||||
const { deps, store } = createDeps();
|
const { deps, store } = createDeps();
|
||||||
const reader = new MemberWorkSyncDiagnosticsReader(deps);
|
const reader = new MemberWorkSyncDiagnosticsReader(deps);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue