fix(plugin-center): renew coalesced load intent
This commit is contained in:
@@ -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:<projectId>` 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:<projectId>` 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.
|
||||
@@ -44,6 +44,7 @@ export function createCodingPluginsStore(overrides: Partial<Dependencies> = {}):
|
||||
removeProject: removeDataServiceProject, ...overrides,
|
||||
};
|
||||
const flights = new Map<string, Promise<void>>();
|
||||
const loadFlightGenerations = new Map<string, number>();
|
||||
const pendingCounts = new Map<string, number>();
|
||||
const projectEpochs = new Map<string, number>();
|
||||
let loadGeneration = 0;
|
||||
@@ -67,7 +68,10 @@ export function createCodingPluginsStore(overrides: Partial<Dependencies> = {}):
|
||||
pendingCounts.set(pendingKey, pendingCount);
|
||||
let flight: Promise<void>;
|
||||
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<Dependencies> = {}):
|
||||
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<Dependencies> = {}):
|
||||
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;
|
||||
|
||||
@@ -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<string, ReturnType<typeof deferred<ReturnType<typeof projection>>>>();
|
||||
const list = vi.fn((projectId: string) => {
|
||||
const request = deferred<ReturnType<typeof projection>>();
|
||||
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<ReturnType<typeof projection>>();
|
||||
const store = createCodingPluginsStore({
|
||||
|
||||
Reference in New Issue
Block a user