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
|
||||
|
||||
**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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue