fix(member-work-sync): keep diagnostics reads passive
This commit is contained in:
parent
99b636fd33
commit
325f1ffba2
3 changed files with 57 additions and 23 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
# Member Work Sync Control Plane Plan
|
# 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
|
**Scope:** Team management, task work synchronization, agent work coordination
|
||||||
**Primary repo:** `claude_team`
|
**Primary repo:** `claude_team`
|
||||||
**Secondary write-boundary repo:** `agent_teams_orchestrator` / `agent-teams-controller`
|
**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 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 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 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 a durable outbox: idempotency key, payload hash conflict checks, claim generation fencing, retry/terminal states.
|
||||||
- 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.
|
- 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 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.
|
- 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:
|
Patterns used:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,9 @@ export class MemberWorkSyncReconciler {
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.deps.statusStore.write(status);
|
await this.deps.statusStore.write(status);
|
||||||
await this.planNudgeOutbox(status);
|
if ((context.reconciledBy ?? 'request') === 'queue') {
|
||||||
|
await this.planNudgeOutbox(status);
|
||||||
|
}
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
MemberWorkSyncDiagnosticsReader,
|
MemberWorkSyncDiagnosticsReader,
|
||||||
MemberWorkSyncNudgeDispatcher,
|
MemberWorkSyncNudgeDispatcher,
|
||||||
MemberWorkSyncPendingReportIntentReplayer,
|
MemberWorkSyncPendingReportIntentReplayer,
|
||||||
|
MemberWorkSyncReconciler,
|
||||||
MemberWorkSyncReporter,
|
MemberWorkSyncReporter,
|
||||||
type MemberWorkSyncAgendaSourceResult,
|
type MemberWorkSyncAgendaSourceResult,
|
||||||
type MemberWorkSyncInboxNudgePort,
|
type MemberWorkSyncInboxNudgePort,
|
||||||
|
|
@ -429,6 +430,22 @@ describe('MemberWorkSync use cases', () => {
|
||||||
const outbox = new InMemoryOutboxStore();
|
const outbox = new InMemoryOutboxStore();
|
||||||
const { deps } = createDeps({ outboxStore: outbox });
|
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({
|
await new MemberWorkSyncDiagnosticsReader(deps).execute({
|
||||||
teamName: 'team-a',
|
teamName: 'team-a',
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
|
|
@ -442,10 +459,13 @@ describe('MemberWorkSync use cases', () => {
|
||||||
const { deps, store } = createDeps({ outboxStore: outbox });
|
const { deps, store } = createDeps({ outboxStore: outbox });
|
||||||
store.phase2ReadinessState = 'shadow_ready';
|
store.phase2ReadinessState = 'shadow_ready';
|
||||||
|
|
||||||
const status = await new MemberWorkSyncDiagnosticsReader(deps).execute({
|
const status = await new MemberWorkSyncReconciler(deps).execute(
|
||||||
teamName: 'team-a',
|
{
|
||||||
memberName: 'bob',
|
teamName: 'team-a',
|
||||||
});
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||||
|
);
|
||||||
|
|
||||||
expect(outbox.ensures).toHaveLength(1);
|
expect(outbox.ensures).toHaveLength(1);
|
||||||
expect(outbox.ensures[0]).toMatchObject({
|
expect(outbox.ensures[0]).toMatchObject({
|
||||||
|
|
@ -470,10 +490,13 @@ describe('MemberWorkSync use cases', () => {
|
||||||
const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox });
|
const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox });
|
||||||
store.phase2ReadinessState = 'shadow_ready';
|
store.phase2ReadinessState = 'shadow_ready';
|
||||||
|
|
||||||
const status = await new MemberWorkSyncDiagnosticsReader(deps).execute({
|
const status = await new MemberWorkSyncReconciler(deps).execute(
|
||||||
teamName: 'team-a',
|
{
|
||||||
memberName: 'bob',
|
teamName: 'team-a',
|
||||||
});
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||||
|
);
|
||||||
const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||||
teamNames: ['team-a'],
|
teamNames: ['team-a'],
|
||||||
claimedBy: 'test-dispatcher',
|
claimedBy: 'test-dispatcher',
|
||||||
|
|
@ -498,9 +521,12 @@ describe('MemberWorkSync use cases', () => {
|
||||||
const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox });
|
const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox });
|
||||||
store.phase2ReadinessState = 'shadow_ready';
|
store.phase2ReadinessState = 'shadow_ready';
|
||||||
|
|
||||||
const reader = new MemberWorkSyncDiagnosticsReader(deps);
|
const reconciler = new MemberWorkSyncReconciler(deps);
|
||||||
const reporter = new MemberWorkSyncReporter(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({
|
await reporter.execute({
|
||||||
teamName: 'team-a',
|
teamName: 'team-a',
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
|
|
@ -530,10 +556,13 @@ describe('MemberWorkSync use cases', () => {
|
||||||
const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox });
|
const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox });
|
||||||
store.phase2ReadinessState = 'shadow_ready';
|
store.phase2ReadinessState = 'shadow_ready';
|
||||||
|
|
||||||
const current = await new MemberWorkSyncDiagnosticsReader(deps).execute({
|
const current = await new MemberWorkSyncReconciler(deps).execute(
|
||||||
teamName: 'team-a',
|
{
|
||||||
memberName: 'bob',
|
teamName: 'team-a',
|
||||||
});
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||||
|
);
|
||||||
const firstId = `member-work-sync:team-a:bob:${current.agenda.fingerprint}:old-1`;
|
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 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}`);
|
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 });
|
const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox });
|
||||||
store.phase2ReadinessState = 'shadow_ready';
|
store.phase2ReadinessState = 'shadow_ready';
|
||||||
|
|
||||||
const current = await new MemberWorkSyncDiagnosticsReader(deps).execute({
|
const current = await new MemberWorkSyncReconciler(deps).execute(
|
||||||
teamName: 'team-a',
|
{
|
||||||
memberName: 'bob',
|
teamName: 'team-a',
|
||||||
});
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||||
|
);
|
||||||
const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||||
teamNames: ['team-a'],
|
teamNames: ['team-a'],
|
||||||
claimedBy: 'test-dispatcher',
|
claimedBy: 'test-dispatcher',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue