fix(member-work-sync): keep diagnostics reads passive

This commit is contained in:
777genius 2026-04-29 15:42:08 +03:00
parent 99b636fd33
commit 325f1ffba2
3 changed files with 57 additions and 23 deletions

View file

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

View file

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

View file

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