diff --git a/.project-docs/30-worklog/tasks/20260827-plugin-ml07-remediation-r4-8a5b3e02.md b/.project-docs/30-worklog/tasks/20260827-plugin-ml07-remediation-r4-8a5b3e02.md new file mode 100644 index 0000000..0e66545 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260827-plugin-ml07-remediation-r4-8a5b3e02.md @@ -0,0 +1,60 @@ +# Task: Remediate accepted MakeLore ML-07 R4 finding + +## Identity + +- Task ID: 20260827-plugin-ml07-remediation-r4-8a5b3e02 +- Mode: Feature +- Branch: codex/20260827-plugin-ml07-remediation-r4-8a5b3e02-plugin-ml07-remediation-r4 +- Worktree: D:\Datas\OthersProjects\makelore-plugin-ml07-remediation-r4-8a5b3e02 +- Base commit: 07ea9da858b4a412191747a6c80f465cf2d46d45 +- Owner: ml07-remediator +- Status: Ready for integration + +## Scope + +- Fix the existing Plugin Center store load-flight generation handling for the exact pending A1 → pending B → A2 sequence. +- Add focused regression coverage for both A/B completion orders while retaining consecutive duplicate-A coalescing, stable public pending keys, ordinary A-to-B latest selection, and all R3 mutation-epoch/reload behavior. +- Change only `src/stores/coding-plugins.ts`, `tests/unit/coding-plugins-store.test.ts`, and this task record. + +## Intent And Constraints + +- A2 is a renewed latest intent for project A even when it shares A1's in-flight request; that shared flight must be promoted to the newest load generation so B cannot commit and A can commit without retry. +- Do not merely add a latest-project check to B, because the old-generation A flight would still be unable to commit. Do not blindly increment generation before returning a coalesced flight without promoting that flight, because consecutive duplicate loads would lose commit eligibility. +- Preserve epoch-specific load invalidation, authoritative mutation reloads, stable `load:` pending keys, and project-scoped mutation guards from R3. Add no UI behavior or new abstraction layer. +- Work only in the isolated exact-base task worktree; do not touch the user root/coordinator, revert peers, push, or open a PR. + +## Project Context Loaded + +- Concurrent Task Gate and Planning Gate passed for the exact task ID, feature mode, branch, worktree, base `07ea9da858b4a412191747a6c80f465cf2d46d45`, and owner `ml07-remediator`. +- Read the project memory startup set, architecture/data-flow/domain/success criteria, ADR-006, evidence/reflection/commitment/stale indexes, coordinator scope, R4 reviewer scopes, implementation spec §§8 and 10.3, and ML-07 fixed review/remediation ticket. +- Coordinator owns integration; R4 standards/spec tasks are read-only. No active peer has a competing semantic decision or ownership overlap with the two product/test files. +- Plugin Center mutations must remain pending/deduplicated, project state stays Renderer-local/project-scoped, and this correction does not change Main/Pi/plugin policy authority. + +## Plan + +1. Add a red table test for A1 pending → B pending → A2, resolving B/A in both orders; assert A1/A2 share one request/promise, B never wins, final A contains A's response, and public pending keys contain no internal generation. +2. Attach mutable generation ownership to each in-flight load. A repeated intent promotes the shared flight to a newly allocated latest generation before returning it; its completion remains eligible, while intervening B becomes stale. +3. Run the store suite plus adjacent Plugin Center client/page/settings tests, typecheck, scoped/full lint, diff/doc gates, then return one clean sole-parent commit. + +## Outcome + +- Added intent-aware generation ownership for keyed load flights. Re-selecting a pending load promotes that existing flight to the newest generation without issuing a second request, so an intervening project load cannot commit and the renewed flight can commit without retry. +- Preserved epoch invalidation, mutation-triggered authoritative reloads, ordinary latest-project selection, shared-flight promise identity, and stable public `load:` pending keys. +- Added both A1/B/A2 completion-order regressions. Before the implementation, the B-first case committed B immediately and the A-first case ended on B; both now end on the renewed A response. + +## Verification + +- RED: `corepack pnpm exec vitest run tests/unit/coding-plugins-store.test.ts` — 2 new failures / 13 existing passes, demonstrating both incorrect completion orders. +- GREEN: `corepack pnpm exec vitest run tests/unit/coding-plugins-store.test.ts` — 15/15 passed. +- Adjacent focused suite: `corepack pnpm exec vitest run tests/unit/coding-plugins-store.test.ts tests/unit/coding-plugins-client.test.ts tests/unit/project-plugins-page.test.tsx tests/unit/data-service-plugin-settings.test.tsx` — 24/24 passed across 4 files. +- `corepack pnpm run typecheck` — passed. +- `corepack pnpm exec eslint src/stores/coding-plugins.ts tests/unit/coding-plugins-store.test.ts` — passed with no findings. +- `corepack pnpm run lint:check` — passed with 0 errors and 5 pre-existing warnings in `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`, outside this task's ownership. + +## Follow-ups + +- None recorded. + +## Promotion Candidates + +- None recorded. diff --git a/src/stores/coding-plugins.ts b/src/stores/coding-plugins.ts index fafd380..ef1f6fc 100644 --- a/src/stores/coding-plugins.ts +++ b/src/stores/coding-plugins.ts @@ -44,6 +44,7 @@ export function createCodingPluginsStore(overrides: Partial = {}): removeProject: removeDataServiceProject, ...overrides, }; const flights = new Map>(); + const loadFlightGenerations = new Map(); const pendingCounts = new Map(); const projectEpochs = new Map(); let loadGeneration = 0; @@ -67,7 +68,10 @@ export function createCodingPluginsStore(overrides: Partial = {}): pendingCounts.set(pendingKey, pendingCount); let flight: Promise; flight = run().finally(() => { - if (flights.get(key) === flight) flights.delete(key); + if (flights.get(key) === flight) { + flights.delete(key); + loadFlightGenerations.delete(key); + } const remaining = (pendingCounts.get(pendingKey) ?? 1) - 1; if (remaining > 0) { pendingCounts.set(pendingKey, remaining); @@ -92,8 +96,11 @@ export function createCodingPluginsStore(overrides: Partial = {}): const pendingKey = `load:${projectId}`; const key = `${pendingKey}:${epoch}`; const existing = flights.get(key); - if (existing) return existing; - const generation = ++loadGeneration; + if (existing) { + loadFlightGenerations.set(key, ++loadGeneration); + return existing; + } + loadFlightGenerations.set(key, ++loadGeneration); return operation(key, async () => { set({ loadState: 'loading', error: null }); try { @@ -104,11 +111,11 @@ export function createCodingPluginsStore(overrides: Partial = {}): const result = await deps.inspectDataService(); if (result.success && result.data) dataService = result.data; } - if (generation === loadGeneration && epoch === projectEpoch(projectId)) { + if (loadFlightGenerations.get(key) === loadGeneration && epoch === projectEpoch(projectId)) { set({ projectId, projection, dataService, loadState: 'ready', error: null }); } } catch (error) { - if (generation === loadGeneration && epoch === projectEpoch(projectId)) { + if (loadFlightGenerations.get(key) === loadGeneration && epoch === projectEpoch(projectId)) { set({ loadState: 'error', error: error instanceof Error ? error.message : String(error) }); } throw error; diff --git a/tests/unit/coding-plugins-store.test.ts b/tests/unit/coding-plugins-store.test.ts index 5ead9d4..aeaa9a0 100644 --- a/tests/unit/coding-plugins-store.test.ts +++ b/tests/unit/coding-plugins-store.test.ts @@ -118,6 +118,48 @@ describe('coding plugins store', () => { }); }); + it.each(['project-b first', 'project-a first'] as const)( + 'renews a coalesced project load as the latest intent when %s completes', + async (completionOrder) => { + const pending = new Map>>>(); + const list = vi.fn((projectId: string) => { + const request = deferred>(); + pending.set(projectId, request); + return request.promise; + }); + const store = createCodingPluginsStore({ list }); + store.setState({ projectId: 'project-a', projection: projection(false, 'project-a') }); + + const firstProjectA = store.getState().load('project-a'); + const projectB = store.getState().load('project-b'); + const renewedProjectA = store.getState().load('project-a'); + + expect(renewedProjectA).toBe(firstProjectA); + expect(list).toHaveBeenCalledTimes(2); + expect(Object.keys(store.getState().pending).sort()).toEqual([ + 'load:project-a', 'load:project-b', + ]); + + if (completionOrder === 'project-b first') { + pending.get('project-b')?.resolve(projection(false, 'project-b')); + await projectB; + expect(store.getState().projectId).toBe('project-a'); + pending.get('project-a')?.resolve(projection(true, 'project-a')); + } else { + pending.get('project-a')?.resolve(projection(true, 'project-a')); + await firstProjectA; + pending.get('project-b')?.resolve(projection(false, 'project-b')); + } + + await Promise.all([firstProjectA, projectB, renewedProjectA]); + expect(store.getState()).toMatchObject({ + projectId: 'project-a', loadState: 'ready', error: null, + }); + expect(store.getState().projection?.items[0].enabled).toBe(true); + expect(store.getState().pending).toEqual({}); + }, + ); + it('does not let an older same-project load overwrite a completed enable mutation', async () => { const staleLoad = deferred>(); const store = createCodingPluginsStore({