feat(member-work-sync): expose shadow metrics

This commit is contained in:
777genius 2026-04-29 14:27:03 +03:00
parent bf1f3b6b02
commit 246f31bbdb
16 changed files with 388 additions and 5 deletions

View file

@ -1,6 +1,6 @@
# Member Work Sync Control Plane Plan
**Status:** Phase 1 implemented, Phase 2 deferred until shadow metrics are reviewed
**Status:** Phase 1 and Phase 1.5 observability implemented, Phase 2 deferred until shadow metrics are reviewed
**Scope:** Team management, task work synchronization, agent work coordination
**Primary repo:** `claude_team`
**Secondary write-boundary repo:** `agent_teams_orchestrator` / `agent-teams-controller`
@ -31,7 +31,7 @@ Phase 2 adds durable nudges only after Phase 1 metrics prove that fingerprint ch
Current implementation note:
- Phase 1 is intentionally shadow-only: it computes agendas, fingerprints, report tokens, reports, persisted status, passive queue reconciliation, startup replay, diagnostics, and read-only renderer view models.
- Phase 1 is intentionally shadow-only: it computes agendas, fingerprints, report tokens, reports, persisted status, passive queue reconciliation, startup replay, diagnostics, metrics, and read-only renderer view models.
- Phase 1 does not insert inbox messages, send nudges, mark tasks/messages read, or change `TeamTaskStallMonitor` semantics.
- Phase 2 must not start until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low.

View file

@ -1,2 +1,3 @@
export const MEMBER_WORK_SYNC_GET_STATUS = 'member-work-sync:getStatus';
export const MEMBER_WORK_SYNC_GET_METRICS = 'member-work-sync:getMetrics';
export const MEMBER_WORK_SYNC_REPORT = 'member-work-sync:report';

View file

@ -102,6 +102,42 @@ export interface MemberWorkSyncStatus {
providerId?: MemberWorkSyncProviderId;
}
export type MemberWorkSyncMetricEventKind =
| 'status_evaluated'
| 'would_nudge'
| 'fingerprint_changed'
| 'report_accepted'
| 'report_rejected';
export interface MemberWorkSyncMetricEvent {
id: string;
teamName: string;
memberName: string;
kind: MemberWorkSyncMetricEventKind;
state: MemberWorkSyncStatusState;
agendaFingerprint: string;
recordedAt: string;
actionableCount: number;
providerId?: MemberWorkSyncProviderId;
previousFingerprint?: string;
triggerReasons?: string[];
reportState?: MemberWorkSyncReportState;
rejectionCode?: string;
}
export interface MemberWorkSyncTeamMetrics {
teamName: string;
generatedAt: string;
memberCount: number;
stateCounts: Record<MemberWorkSyncStatusState, number>;
actionableItemCount: number;
wouldNudgeCount: number;
fingerprintChangeCount: number;
reportAcceptedCount: number;
reportRejectedCount: number;
recentEvents: MemberWorkSyncMetricEvent[];
}
export interface MemberWorkSyncReportRequest {
teamName: string;
memberName: string;
@ -126,3 +162,7 @@ export interface MemberWorkSyncStatusRequest {
teamName: string;
memberName: string;
}
export interface MemberWorkSyncMetricsRequest {
teamName: string;
}

View file

@ -0,0 +1,35 @@
import type { MemberWorkSyncMetricsRequest, MemberWorkSyncTeamMetrics } from '../../contracts';
import type { MemberWorkSyncUseCaseDeps } from './ports';
function emptyMetrics(teamName: string, generatedAt: string): MemberWorkSyncTeamMetrics {
return {
teamName,
generatedAt,
memberCount: 0,
stateCounts: {
caught_up: 0,
needs_sync: 0,
still_working: 0,
blocked: 0,
inactive: 0,
unknown: 0,
},
actionableItemCount: 0,
wouldNudgeCount: 0,
fingerprintChangeCount: 0,
reportAcceptedCount: 0,
reportRejectedCount: 0,
recentEvents: [],
};
}
export class MemberWorkSyncMetricsReader {
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {}
async execute(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics> {
if (!this.deps.statusStore.readTeamMetrics) {
return emptyMetrics(request.teamName, this.deps.clock.now().toISOString());
}
return this.deps.statusStore.readTeamMetrics(request.teamName);
}
}

View file

@ -2,6 +2,7 @@ import type {
MemberWorkSyncReport,
MemberWorkSyncReportRequest,
MemberWorkSyncReportResult,
MemberWorkSyncStatus,
} from '../../contracts';
import { validateMemberWorkSyncReport } from '../domain';
import {
@ -27,11 +28,12 @@ export class MemberWorkSyncReporter {
: true;
if (!teamActive) {
const status = await this.reconciler.execute(request);
const rejectedStatus = await this.recordRejectedReport(status, request, 'team_runtime_inactive');
return {
accepted: false,
code: 'team_runtime_inactive',
message: 'Team runtime is not active. Restart the team before reporting work sync state.',
status,
status: rejectedStatus,
};
}
const tokenValidation = this.deps.reportToken
@ -53,11 +55,12 @@ export class MemberWorkSyncReporter {
if (!validation.ok) {
const status = await this.reconciler.execute(request);
const rejectedStatus = await this.recordRejectedReport(status, request, validation.code);
return {
accepted: false,
code: validation.code,
message: validation.message,
status,
status: rejectedStatus,
};
}
@ -103,4 +106,29 @@ export class MemberWorkSyncReporter {
status,
};
}
private async recordRejectedReport(
status: MemberWorkSyncStatus,
request: MemberWorkSyncReportRequest,
rejectionCode: string
): Promise<MemberWorkSyncStatus> {
const rejectedStatus: MemberWorkSyncStatus = {
...status,
report: {
teamName: status.teamName,
memberName: status.memberName,
state: request.state,
agendaFingerprint: request.agendaFingerprint,
reportedAt: status.evaluatedAt,
...(request.taskIds ? { taskIds: [...request.taskIds] } : {}),
...(request.note ? { note: request.note } : {}),
source: request.source ?? 'app',
accepted: false,
rejectionCode,
},
diagnostics: [...status.diagnostics, `report_rejected:${rejectionCode}`],
};
await this.deps.statusStore.write(rejectedStatus);
return rejectedStatus;
}
}

View file

@ -1,4 +1,5 @@
export * from './MemberWorkSyncDiagnosticsReader';
export * from './MemberWorkSyncMetricsReader';
export * from './MemberWorkSyncPendingReportIntentReplayer';
export * from './MemberWorkSyncReconciler';
export * from './MemberWorkSyncReporter';

View file

@ -1,5 +1,6 @@
import type {
MemberWorkSyncAgenda,
MemberWorkSyncTeamMetrics,
MemberWorkSyncProviderId,
MemberWorkSyncReport,
MemberWorkSyncReportIntent,
@ -73,6 +74,7 @@ export interface MemberWorkSyncAgendaSourcePort {
export interface MemberWorkSyncStatusStorePort {
read(input: { teamName: string; memberName: string }): Promise<MemberWorkSyncStatus | null>;
write(status: MemberWorkSyncStatus): Promise<void>;
readTeamMetrics?(teamName: string): Promise<MemberWorkSyncTeamMetrics>;
}
export interface MemberWorkSyncReportStorePort {

View file

@ -1,10 +1,13 @@
import {
MEMBER_WORK_SYNC_GET_METRICS,
MEMBER_WORK_SYNC_GET_STATUS,
MEMBER_WORK_SYNC_REPORT,
type MemberWorkSyncMetricsRequest,
type MemberWorkSyncReportRequest,
type MemberWorkSyncReportResult,
type MemberWorkSyncStatus,
type MemberWorkSyncStatusRequest,
type MemberWorkSyncTeamMetrics,
} from '../../../contracts';
import { createLogger } from '@shared/utils/logger';
@ -29,6 +32,18 @@ export function registerMemberWorkSyncIpc(
}
);
ipcMain.handle(
MEMBER_WORK_SYNC_GET_METRICS,
async (_event, request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics> => {
try {
return await feature.getMetrics(request);
} catch (error) {
logger.error('Failed to get member work sync metrics', error);
throw error;
}
}
);
ipcMain.handle(
MEMBER_WORK_SYNC_REPORT,
async (_event, request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult> => {
@ -44,5 +59,6 @@ export function registerMemberWorkSyncIpc(
export function removeMemberWorkSyncIpc(ipcMain: IpcMain): void {
ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_STATUS);
ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_METRICS);
ipcMain.removeHandler(MEMBER_WORK_SYNC_REPORT);
}

View file

@ -1,11 +1,14 @@
import type {
MemberWorkSyncMetricsRequest,
MemberWorkSyncReportRequest,
MemberWorkSyncReportResult,
MemberWorkSyncStatus,
MemberWorkSyncStatusRequest,
MemberWorkSyncTeamMetrics,
} from '../../contracts';
import {
MemberWorkSyncDiagnosticsReader,
MemberWorkSyncMetricsReader,
MemberWorkSyncPendingReportIntentReplayer,
type MemberWorkSyncPendingReportReplaySummary,
MemberWorkSyncReconciler,
@ -33,6 +36,7 @@ import type { MemberWorkSyncLoggerPort } from '../../core/application';
export interface MemberWorkSyncFeatureFacade {
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
noteTeamChange(event: TeamChangeEvent): void;
enqueueStartupScan(teamNames: string[]): Promise<void>;
@ -74,6 +78,7 @@ export function createMemberWorkSyncFeature(deps: {
logger: deps.logger,
};
const diagnosticsReader = new MemberWorkSyncDiagnosticsReader(useCaseDeps);
const metricsReader = new MemberWorkSyncMetricsReader(useCaseDeps);
const reporter = new MemberWorkSyncReporter(useCaseDeps);
const reconciler = new MemberWorkSyncReconciler(useCaseDeps);
const pendingReportReplayer = new MemberWorkSyncPendingReportIntentReplayer(useCaseDeps);
@ -88,6 +93,7 @@ export function createMemberWorkSyncFeature(deps: {
return {
getStatus: (request) => diagnosticsReader.execute(request),
getMetrics: (request) => metricsReader.execute(request),
report: (request) => reporter.execute(request),
noteTeamChange: (event) => router.noteTeamChange(event),
enqueueStartupScan: (teamNames) => router.enqueueStartupScan(teamNames),

View file

@ -4,9 +4,12 @@ import { mkdir, readFile, rename } from 'fs/promises';
import { withFileLock } from '@main/services/team/fileLock';
import type {
MemberWorkSyncMetricEvent,
MemberWorkSyncReportIntent,
MemberWorkSyncReportRequest,
MemberWorkSyncStatus,
MemberWorkSyncStatusState,
MemberWorkSyncTeamMetrics,
} from '../../contracts';
import type {
MemberWorkSyncReportStorePort,
@ -17,6 +20,9 @@ import type { MemberWorkSyncStorePaths } from './MemberWorkSyncStorePaths';
interface StoreFile {
schemaVersion: 1;
members: Record<string, MemberWorkSyncStatus>;
metrics?: {
recentEvents: MemberWorkSyncMetricEvent[];
};
}
interface PendingReportFile {
@ -39,6 +45,17 @@ function isStoreFile(value: unknown): value is StoreFile {
);
}
function emptyStateCounts(): Record<MemberWorkSyncStatusState, number> {
return {
caught_up: 0,
needs_sync: 0,
still_working: 0,
blocked: 0,
inactive: 0,
unknown: 0,
};
}
function isPendingReportFile(value: unknown): value is PendingReportFile {
return (
value != null &&
@ -82,6 +99,91 @@ function buildPendingReportIntentId(request: MemberWorkSyncReportRequest): strin
.digest('hex')}`;
}
function buildMetricEventId(status: MemberWorkSyncStatus, kind: MemberWorkSyncMetricEvent['kind']) {
return `member-work-sync-metric:${createHash('sha256')
.update(
stableStringify({
teamName: status.teamName,
memberName: normalizeMemberKey(status.memberName),
kind,
state: status.state,
agendaFingerprint: status.agenda.fingerprint,
evaluatedAt: status.evaluatedAt,
reportState: status.report?.state ?? '',
rejectionCode: status.report?.rejectionCode ?? '',
})
)
.digest('hex')}`;
}
function buildMetricEvents(status: MemberWorkSyncStatus): MemberWorkSyncMetricEvent[] {
const base = {
teamName: status.teamName,
memberName: status.memberName,
state: status.state,
agendaFingerprint: status.agenda.fingerprint,
recordedAt: status.evaluatedAt,
actionableCount: status.agenda.items.length,
...(status.providerId ? { providerId: status.providerId } : {}),
...(status.shadow?.previousFingerprint
? { previousFingerprint: status.shadow.previousFingerprint }
: {}),
...(status.shadow?.triggerReasons?.length
? { triggerReasons: [...status.shadow.triggerReasons] }
: {}),
...(status.report?.state ? { reportState: status.report.state } : {}),
...(status.report?.rejectionCode ? { rejectionCode: status.report.rejectionCode } : {}),
};
const events: MemberWorkSyncMetricEvent[] = [
{
...base,
id: buildMetricEventId(status, 'status_evaluated'),
kind: 'status_evaluated',
},
];
if (status.shadow?.wouldNudge) {
events.push({
...base,
id: buildMetricEventId(status, 'would_nudge'),
kind: 'would_nudge',
});
}
if (status.shadow?.fingerprintChanged) {
events.push({
...base,
id: buildMetricEventId(status, 'fingerprint_changed'),
kind: 'fingerprint_changed',
});
}
if (status.report?.accepted) {
events.push({
...base,
id: buildMetricEventId(status, 'report_accepted'),
kind: 'report_accepted',
});
} else if (status.report?.rejectionCode) {
events.push({
...base,
id: buildMetricEventId(status, 'report_rejected'),
kind: 'report_rejected',
});
}
return events;
}
function appendMetricEvents(file: StoreFile, status: MemberWorkSyncStatus): void {
const current = file.metrics?.recentEvents ?? [];
const byId = new Map(current.map((event) => [event.id, event]));
for (const event of buildMetricEvents(status)) {
byId.set(event.id, event);
}
file.metrics = {
recentEvents: [...byId.values()]
.sort((left, right) => left.recordedAt.localeCompare(right.recordedAt))
.slice(-200),
};
}
async function quarantineFile(filePath: string): Promise<void> {
try {
await rename(filePath, `${filePath}.invalid.${Date.now()}`);
@ -110,6 +212,7 @@ export class JsonMemberWorkSyncStore
await withFileLock(this.paths.getStatusPath(status.teamName), async () => {
const existing = await this.readFile(status.teamName);
existing.members[normalizeMemberKey(status.memberName)] = status;
appendMetricEvents(existing, status);
await mkdir(this.paths.getTeamDir(status.teamName), { recursive: true });
await atomicWriteAsync(
this.paths.getStatusPath(status.teamName),
@ -119,6 +222,36 @@ export class JsonMemberWorkSyncStore
});
}
async readTeamMetrics(teamName: string): Promise<MemberWorkSyncTeamMetrics> {
const file = await this.readFile(teamName);
const stateCounts = emptyStateCounts();
const members = Object.values(file.members);
let actionableItemCount = 0;
for (const status of members) {
stateCounts[status.state] += 1;
actionableItemCount += status.agenda.items.length;
}
const recentEvents = [...(file.metrics?.recentEvents ?? [])].sort((left, right) =>
left.recordedAt.localeCompare(right.recordedAt)
);
return {
teamName,
generatedAt: new Date().toISOString(),
memberCount: members.length,
stateCounts,
actionableItemCount,
wouldNudgeCount: recentEvents.filter((event) => event.kind === 'would_nudge').length,
fingerprintChangeCount: recentEvents.filter(
(event) => event.kind === 'fingerprint_changed'
).length,
reportAcceptedCount: recentEvents.filter((event) => event.kind === 'report_accepted')
.length,
reportRejectedCount: recentEvents.filter((event) => event.kind === 'report_rejected')
.length,
recentEvents,
};
}
async appendPendingReport(request: MemberWorkSyncReportRequest, reason: string): Promise<void> {
const id = buildPendingReportIntentId(request);
await this.enqueue(request.teamName, async () => {
@ -190,7 +323,7 @@ export class JsonMemberWorkSyncStore
await quarantineFile(filePath);
}
}
return { schemaVersion: 1, members: {} };
return { schemaVersion: 1, members: {}, metrics: { recentEvents: [] } };
}
private async readPendingFile(teamName: string): Promise<PendingReportFile> {

View file

@ -1,22 +1,27 @@
import {
MEMBER_WORK_SYNC_GET_METRICS,
MEMBER_WORK_SYNC_GET_STATUS,
MEMBER_WORK_SYNC_REPORT,
type MemberWorkSyncMetricsRequest,
type MemberWorkSyncReportRequest,
type MemberWorkSyncReportResult,
type MemberWorkSyncStatus,
type MemberWorkSyncStatusRequest,
type MemberWorkSyncTeamMetrics,
} from '../contracts';
import type { IpcRenderer } from 'electron';
export interface MemberWorkSyncElectronApi {
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
}
export function createMemberWorkSyncBridge(ipcRenderer: IpcRenderer): MemberWorkSyncElectronApi {
return {
getStatus: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_GET_STATUS, request),
getMetrics: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_GET_METRICS, request),
report: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_REPORT, request),
};
}

View file

@ -751,6 +751,31 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
}
);
app.get<{ Params: { teamName: string } }>(
'/api/teams/:teamName/member-work-sync/metrics',
async (request, reply) => {
try {
const validatedTeamName = validateTeamName(request.params.teamName);
if (!validatedTeamName.valid) {
return reply.status(400).send({ error: validatedTeamName.error });
}
return reply.send(
await getMemberWorkSyncFeature(services).getMetrics({
teamName: validatedTeamName.value!,
})
);
} catch (error) {
if (shouldLogError(error)) {
logger.error(
`Error in GET /api/teams/${request.params.teamName}/member-work-sync/metrics:`,
getErrorMessage(error)
);
}
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
}
}
);
app.get<{ Params: { teamName: string; memberName: string } }>(
'/api/teams/:teamName/member-work-sync/:memberName',
async (request, reply) => {

View file

@ -1296,6 +1296,8 @@ export class HttpAPIClient implements ElectronAPI {
request.memberName
)}`
),
getMetrics: (request) =>
this.get(`/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/metrics`),
report: (request) =>
this.post(
`/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/report`,

View file

@ -98,10 +98,12 @@ import type { CodexAccountElectronApi } from '@features/codex-account/contracts'
import type { RecentProjectsElectronApi } from '@features/recent-projects/contracts';
import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts';
import type {
MemberWorkSyncMetricsRequest,
MemberWorkSyncReportRequest,
MemberWorkSyncReportResult,
MemberWorkSyncStatus,
MemberWorkSyncStatusRequest,
MemberWorkSyncTeamMetrics,
} from '@features/member-work-sync/contracts';
import type {
ConversationGroup,
@ -612,6 +614,7 @@ export interface TeamsAPI {
export interface MemberWorkSyncElectronApi {
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
}

View file

@ -214,6 +214,12 @@ describe('MemberWorkSync use cases', () => {
expect(result.accepted).toBe(false);
expect(result.code).toBe('stale_fingerprint');
expect(result.status.state).toBe('needs_sync');
expect(result.status.report).toMatchObject({
accepted: false,
rejectionCode: 'stale_fingerprint',
agendaFingerprint: 'agenda:v1:stale',
});
expect(store.writes.at(-1)?.diagnostics).toContain('report_rejected:stale_fingerprint');
expect(store.pendingReports).toHaveLength(0);
});
@ -288,6 +294,10 @@ describe('MemberWorkSync use cases', () => {
expect(result.accepted).toBe(false);
expect(result.code).toBe('invalid_report_token');
expect(result.status.report).toMatchObject({
accepted: false,
rejectionCode: 'invalid_report_token',
});
expect(store.pendingReports).toHaveLength(0);
});

View file

@ -5,6 +5,42 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { JsonMemberWorkSyncStore } from '@features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore';
import { MemberWorkSyncStorePaths } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths';
import type { MemberWorkSyncStatus } from '@features/member-work-sync/contracts';
function makeStatus(overrides: Partial<MemberWorkSyncStatus>): MemberWorkSyncStatus {
return {
teamName: 'team-a',
memberName: 'bob',
state: 'needs_sync',
agenda: {
teamName: 'team-a',
memberName: 'bob',
generatedAt: '2026-04-29T00:00:00.000Z',
fingerprint: 'agenda:v1:abc',
items: [
{
taskId: 'task-1',
displayId: '11111111',
subject: 'Ship UI',
kind: 'work',
assignee: 'bob',
priority: 'normal',
reason: 'owned_pending_task',
evidence: { status: 'pending', owner: 'bob' },
},
],
diagnostics: [],
},
shadow: {
reconciledBy: 'queue',
wouldNudge: true,
fingerprintChanged: false,
},
evaluatedAt: '2026-04-29T00:00:00.000Z',
diagnostics: [],
...overrides,
};
}
describe('JsonMemberWorkSyncStore', () => {
let root: string;
@ -69,4 +105,44 @@ describe('JsonMemberWorkSyncStore', () => {
resultCode: 'accepted',
});
});
it('records bounded shadow metrics from status writes', async () => {
await store.write(makeStatus({}));
await store.write(
makeStatus({
agenda: {
teamName: 'team-a',
memberName: 'bob',
generatedAt: '2026-04-29T00:01:00.000Z',
fingerprint: 'agenda:v1:def',
items: [],
diagnostics: [],
},
state: 'caught_up',
shadow: {
reconciledBy: 'request',
wouldNudge: false,
fingerprintChanged: true,
previousFingerprint: 'agenda:v1:abc',
},
evaluatedAt: '2026-04-29T00:01:00.000Z',
})
);
const metrics = await store.readTeamMetrics('team-a');
expect(metrics).toMatchObject({
teamName: 'team-a',
memberCount: 1,
actionableItemCount: 0,
wouldNudgeCount: 1,
fingerprintChangeCount: 1,
});
expect(metrics.stateCounts.caught_up).toBe(1);
expect(metrics.recentEvents.map((event) => event.kind)).toEqual([
'status_evaluated',
'would_nudge',
'status_evaluated',
'fingerprint_changed',
]);
});
});