chore(member-work-sync): clarify lifecycle dispatch boundary

This commit is contained in:
777genius 2026-04-29 16:09:46 +03:00
parent d27c1bcc51
commit 90401b41a1
5 changed files with 18 additions and 16 deletions

View file

@ -3045,15 +3045,15 @@ Current implementation:
## 22. Runtime Defaults And No Feature Flags
Phase 1 should ship without feature flags.
Phase 1 shipped without feature flags.
Reason:
- Phase 1 has no nudges, no inbox writes, no task mutation, and no runtime restart behavior.
- Adding feature flags for passive status/report validation creates extra branches and makes failures harder to reason about.
- The safe boundary is architectural, not configurational: Phase 1 code simply does not contain the side-effect dispatcher.
- The safe boundary is architectural, not configurational: passive status/report validation stays independent from Phase 2 side effects.
Phase 1 defaults:
Runtime defaults:
| Behavior | Default | Why |
|---|---:|---|
@ -3061,7 +3061,9 @@ Phase 1 defaults:
| `member_work_sync_status` | on | read-only diagnostics |
| `member_work_sync_report` | on | server-validated, no board mutation |
| pending report intent fallback | on only when identity is not terminally invalid | compatibility with old app/runtime boundaries |
| nudges/outbox/inbox writes | not implemented | avoids hidden flag branches |
| outbox planning | on only for queued reconciles and only when `phase2Readiness=shadow_ready` | prevents status reads from causing side effects |
| scheduled nudge dispatch | on only for lifecycle-active teams | stopped teams must not claim or supersede pending nudges |
| inbox nudge writes | guarded by dispatcher revalidation | lifecycle, current fingerprint, readiness, busy signal, rate limit, and watchdog cooldown are checked immediately before write |
Do not add:
@ -3073,9 +3075,9 @@ If Phase 1 needs to be disabled during development, revert or patch the narrow c
Phase 2 policy:
- Phase 2 is a separate implementation, not a disabled code path hidden behind a flag in Phase 1.
- If Phase 2 adds nudges, it must add dispatcher/outbox code in its own cut after metrics review.
- Phase 2 may use constants/configuration for rate limits and timing, but not a broad "new vs legacy" branch.
- Phase 2 is implemented as a separate outbox/dispatcher/scheduler path, not as hidden branching inside passive diagnostics.
- Phase 2 does not bypass shadow readiness. If metrics are noisy, the planner returns `phase2_not_ready`.
- Phase 2 uses constants/configuration for rate limits and timing, but not a broad "new vs legacy" branch.
Phase 2 runtime constants can be normal typed defaults, not feature gates:

View file

@ -59,7 +59,7 @@ export function createMemberWorkSyncFeature(deps: {
kanbanManager: TeamKanbanManager;
membersMetaStore: TeamMembersMetaStore;
isTeamActive?: (teamName: string) => Promise<boolean> | boolean;
listActiveTeamNames?: () => Promise<string[]>;
listLifecycleActiveTeamNames?: () => Promise<string[]>;
logger?: MemberWorkSyncLoggerPort;
}): MemberWorkSyncFeatureFacade {
const clock = new SystemClockAdapter();
@ -110,9 +110,9 @@ export function createMemberWorkSyncFeature(deps: {
logger: deps.logger,
});
const router = new MemberWorkSyncTeamChangeRouter(agendaSource, queue);
const nudgeDispatchScheduler = deps.listActiveTeamNames
const nudgeDispatchScheduler = deps.listLifecycleActiveTeamNames
? new MemberWorkSyncNudgeDispatchScheduler({
listActiveTeamNames: deps.listActiveTeamNames,
listLifecycleActiveTeamNames: deps.listLifecycleActiveTeamNames,
dispatchDue: (teamNames) =>
nudgeDispatcher.dispatchDue({
teamNames,

View file

@ -14,7 +14,7 @@ function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
}
export interface MemberWorkSyncNudgeDispatchSchedulerDeps {
listActiveTeamNames(): Promise<string[]>;
listLifecycleActiveTeamNames(): Promise<string[]>;
dispatchDue(teamNames: string[]): Promise<MemberWorkSyncNudgeDispatchSummary>;
intervalMs?: number;
logger?: MemberWorkSyncLoggerPort;
@ -84,7 +84,7 @@ export class MemberWorkSyncNudgeDispatchScheduler {
private async dispatchOnce(): Promise<void> {
try {
const teamNames = uniqueNonEmpty(await this.deps.listActiveTeamNames());
const teamNames = uniqueNonEmpty(await this.deps.listLifecycleActiveTeamNames());
if (teamNames.length === 0) {
return;
}

View file

@ -1236,7 +1236,7 @@ async function initializeServices(): Promise<void> {
isTeamActive: (teamName) =>
teamProvisioningService.isTeamAlive(teamName) ||
teamProvisioningService.hasProvisioningRun(teamName),
listActiveTeamNames: async () => {
listLifecycleActiveTeamNames: async () => {
const teams = await teamDataService.listTeams();
return teams
.filter(

View file

@ -13,7 +13,7 @@ describe('MemberWorkSyncNudgeDispatchScheduler', () => {
return { claimed: 1, delivered: 1, superseded: 0, retryable: 0, terminal: 0 };
});
const scheduler = new MemberWorkSyncNudgeDispatchScheduler({
listActiveTeamNames: async () => ['team-a', 'team-a', ' ', 'team-b'],
listLifecycleActiveTeamNames: async () => ['team-a', 'team-a', ' ', 'team-b'],
dispatchDue,
});
@ -31,7 +31,7 @@ describe('MemberWorkSyncNudgeDispatchScheduler', () => {
it('skips dispatch when there are no active teams', async () => {
const dispatchDue = vi.fn();
const scheduler = new MemberWorkSyncNudgeDispatchScheduler({
listActiveTeamNames: async () => [],
listLifecycleActiveTeamNames: async () => [],
dispatchDue,
});
@ -43,7 +43,7 @@ describe('MemberWorkSyncNudgeDispatchScheduler', () => {
it('logs and survives list failures without throwing', async () => {
const warn = vi.fn();
const scheduler = new MemberWorkSyncNudgeDispatchScheduler({
listActiveTeamNames: async () => {
listLifecycleActiveTeamNames: async () => {
throw new Error('list failed');
},
dispatchDue: vi.fn(),