From 325f1ffba26ac1d454155c3cc5a077dbd5936c01 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 15:42:08 +0300 Subject: [PATCH] fix(member-work-sync): keep diagnostics reads passive --- .../member-work-sync-control-plane-plan.md | 8 +-- .../application/MemberWorkSyncReconciler.ts | 4 +- .../core/MemberWorkSyncUseCases.test.ts | 68 ++++++++++++++----- 3 files changed, 57 insertions(+), 23 deletions(-) diff --git a/docs/team-management/member-work-sync-control-plane-plan.md b/docs/team-management/member-work-sync-control-plane-plan.md index e71aee38..8aaaef47 100644 --- a/docs/team-management/member-work-sync-control-plane-plan.md +++ b/docs/team-management/member-work-sync-control-plane-plan.md @@ -1,6 +1,6 @@ # Member Work Sync Control Plane Plan -**Status:** Phase 1 and Phase 1.5 observability implemented, minimal read-only member details surface wired, Phase 2 deferred until shadow metrics are reviewed +**Status:** Phase 1, Phase 1.5 observability, minimal read-only member details surface, and readiness-gated Phase 2 nudge outbox/dispatcher implemented **Scope:** Team management, task work synchronization, agent work coordination **Primary repo:** `claude_team` **Secondary write-boundary repo:** `agent_teams_orchestrator` / `agent-teams-controller` @@ -34,11 +34,11 @@ Current implementation note: - Phase 1 is intentionally shadow-only: it computes agendas, fingerprints, report tokens, reports, persisted status, passive queue reconciliation, startup replay, diagnostics, metrics, and a neutral read-only member details surface. - Phase 1 does not insert inbox messages, send nudges, mark tasks/messages read, or change `TeamTaskStallMonitor` semantics. - 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. -- 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. +- Phase 2 storage foundation is implemented as a durable outbox: idempotency key, payload hash conflict checks, claim generation fencing, retry/terminal states. +- Queue reconciles can plan a Phase 2 outbox item only when `phase2Readiness=shadow_ready`; read-only diagnostics never create outbox intents. This preserves the anti-spam gate and keeps UI/status reads passive. - Dispatcher use case runs after queued reconcile and is also exposed through the facade. 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. - Dispatcher applies per-member hourly rate limiting and bounded deterministic retry backoff with jitter before retrying failed nudge attempts. -- Phase 2 must not start until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low. +- Phase 2 dispatch stays blocked until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low. Patterns used: diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts index 1459d06e..3ff508a1 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts @@ -87,7 +87,9 @@ export class MemberWorkSyncReconciler { }); await this.deps.statusStore.write(status); - await this.planNudgeOutbox(status); + if ((context.reconciledBy ?? 'request') === 'queue') { + await this.planNudgeOutbox(status); + } return status; } diff --git a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts index 6d49b96f..f6cb0789 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts @@ -4,6 +4,7 @@ import { MemberWorkSyncDiagnosticsReader, MemberWorkSyncNudgeDispatcher, MemberWorkSyncPendingReportIntentReplayer, + MemberWorkSyncReconciler, MemberWorkSyncReporter, type MemberWorkSyncAgendaSourceResult, type MemberWorkSyncInboxNudgePort, @@ -429,6 +430,22 @@ describe('MemberWorkSync use cases', () => { const outbox = new InMemoryOutboxStore(); const { deps } = createDeps({ outboxStore: outbox }); + await new MemberWorkSyncReconciler(deps).execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + + expect(outbox.ensures).toEqual([]); + }); + + it('does not create outbox nudges from read-only diagnostics requests', async () => { + const outbox = new InMemoryOutboxStore(); + const { deps, store } = createDeps({ outboxStore: outbox }); + store.phase2ReadinessState = 'shadow_ready'; + await new MemberWorkSyncDiagnosticsReader(deps).execute({ teamName: 'team-a', memberName: 'bob', @@ -442,10 +459,13 @@ describe('MemberWorkSync use cases', () => { const { deps, store } = createDeps({ outboxStore: outbox }); store.phase2ReadinessState = 'shadow_ready'; - const status = await new MemberWorkSyncDiagnosticsReader(deps).execute({ - teamName: 'team-a', - memberName: 'bob', - }); + const status = await new MemberWorkSyncReconciler(deps).execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); expect(outbox.ensures).toHaveLength(1); expect(outbox.ensures[0]).toMatchObject({ @@ -470,10 +490,13 @@ describe('MemberWorkSync use cases', () => { 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 status = await new MemberWorkSyncReconciler(deps).execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ teamNames: ['team-a'], claimedBy: 'test-dispatcher', @@ -498,9 +521,12 @@ describe('MemberWorkSync use cases', () => { const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox }); store.phase2ReadinessState = 'shadow_ready'; - const reader = new MemberWorkSyncDiagnosticsReader(deps); + const reconciler = new MemberWorkSyncReconciler(deps); const reporter = new MemberWorkSyncReporter(deps); - const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); + const current = await reconciler.execute( + { teamName: 'team-a', memberName: 'bob' }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); await reporter.execute({ teamName: 'team-a', memberName: 'bob', @@ -530,10 +556,13 @@ describe('MemberWorkSync use cases', () => { const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox }); store.phase2ReadinessState = 'shadow_ready'; - const current = await new MemberWorkSyncDiagnosticsReader(deps).execute({ - teamName: 'team-a', - memberName: 'bob', - }); + const current = await new MemberWorkSyncReconciler(deps).execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); const firstId = `member-work-sync:team-a:bob:${current.agenda.fingerprint}:old-1`; const secondId = `member-work-sync:team-a:bob:${current.agenda.fingerprint}:old-2`; const baseItem = outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`); @@ -569,10 +598,13 @@ describe('MemberWorkSync use cases', () => { const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox }); store.phase2ReadinessState = 'shadow_ready'; - const current = await new MemberWorkSyncDiagnosticsReader(deps).execute({ - teamName: 'team-a', - memberName: 'bob', - }); + const current = await new MemberWorkSyncReconciler(deps).execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ teamNames: ['team-a'], claimedBy: 'test-dispatcher',