fix(plugins): preserve frozen hosted workers on stale library
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
# Task: Diagnose Web Search stale worker resources
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260901-web-search-stale-resources-8b4e2c71
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260901-web-search-stale-resources-8b4e2c71
|
||||
- Worktree: D:\Datas\OthersProjects\makelore-web-search-stale-resources-8b4e2c71
|
||||
- Base commit: 697c68974de8bf43c7094e67372a03123c449343
|
||||
- Owner: codex-root
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Diagnose the reported `409 Web Search worker resources are stale` from the actual
|
||||
installed-client Conversation, establish a deterministic regression at the owning
|
||||
client seam, and implement only the smallest lifecycle/materialization correction
|
||||
supported by evidence.
|
||||
- Own only the client Plugin/Pi resource-generation paths and focused tests proven to
|
||||
cause the failure. Do not change Works Square, pricing, Provider, Marketplace
|
||||
publication, packaged trust, or unrelated Agent behavior.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Preserve the product invariant that one parent Pi worker uses a frozen Plugin
|
||||
snapshot for its lifetime. Account/project/Release/policy or worker-generation
|
||||
changes must continue to invalidate future actions; the fix must not globally relax
|
||||
fail-closed stale-resource checks.
|
||||
- Use the live user-data files only as read-only diagnostic evidence and redact Account
|
||||
tokens/credentials. Do not restart or kill the running MakeLore client without user
|
||||
authorization.
|
||||
- Follow the `diagnosing-bugs` loop: first establish a tight red-capable feedback loop,
|
||||
then state ranked falsifiable hypotheses, add a regression before changing product
|
||||
code, and verify the same loop after the fix.
|
||||
- Preserve the two unrelated untracked package task records in the user root worktree;
|
||||
all task writes stay in this isolated worktree. No push, package, publish, deploy, or
|
||||
paid Provider call is authorized.
|
||||
|
||||
## Project Context Loaded
|
||||
|
||||
Task context:
|
||||
- Task ID: `20260901-web-search-stale-resources-8b4e2c71`
|
||||
- Mode: Feature
|
||||
- Branch: `codex/20260901-web-search-stale-resources-8b4e2c71`
|
||||
- Worktree: `D:\Datas\OthersProjects\makelore-web-search-stale-resources-8b4e2c71`
|
||||
- Base commit: `697c68974de8bf43c7094e67372a03123c449343`
|
||||
- Other active local tasks: the old Web Search coordinator
|
||||
`20260831-web-search-client-5b9d2e63` remains registered at its pre-integration
|
||||
frontier; several historical Marketplace reviewers remain registered in Planning.
|
||||
The user root contains two unowned package task records.
|
||||
- Overlap or semantic-conflict assessment: no current product writer owns the exact
|
||||
main frontier or this diagnosis. The old coordinator and reviewers are historical or
|
||||
read-only at earlier bases. The two root records remain unknown/unowned and untouched;
|
||||
isolation prevents overlap.
|
||||
|
||||
Read:
|
||||
- `.project-docs/05-agent-entry/memory-index.md`
|
||||
- `.project-docs/05-agent-entry/read-before-coding.md`
|
||||
- `.project-docs/05-agent-entry/planning-gate.md`
|
||||
- `.project-docs/00-brief/project-positioning.md`
|
||||
- `.project-docs/30-worklog/current-state.md`
|
||||
- `.project-docs/10-decisions/decision-index.md`
|
||||
- `.project-docs/20-architecture/system-overview.md`
|
||||
- `.project-docs/20-architecture/module-map.md`
|
||||
- `.project-docs/20-architecture/data-flow.md`
|
||||
- `.project-docs/40-domain/business-rules.md`
|
||||
- `.project-docs/00-brief/success-criteria.md`
|
||||
- `.project-docs/40-domain/glossary.md`
|
||||
- `.project-docs/50-evidence/evidence-index.md`
|
||||
- `.project-docs/60-reflection/reflection-index.md`
|
||||
- `.project-docs/80-commitments/commitments.md`
|
||||
- `.project-docs/90-maintenance/stale-items.md`
|
||||
- peer record `20260831-web-search-client-5b9d2e63.md`
|
||||
|
||||
Relevant understanding:
|
||||
- Project goal: MakeLore exposes curated, Main-owned Plugin capabilities to eligible Pi
|
||||
parent workers without leaking Account, Provider, trust, or billing authority.
|
||||
- Current integrated focus: the Web Search package/runtime and assignment catalog fix
|
||||
are on local `main`; the screenshot proves the new Conversation now loads and invokes
|
||||
`makelore_web_search` but fails at invocation-time resource validation.
|
||||
- Active task scope: diagnose and correct only the self-invalidating stale-resource path.
|
||||
- Active constraints: frozen workers must remain frozen; child workers remain empty;
|
||||
official bundled acquisition, project enablement, Agent assignment, current Admission,
|
||||
confirmation, and billing remain separate gates.
|
||||
- Decisions affecting this task: effective Plugin materialization is a single frozen
|
||||
snapshot; supported lifecycle changes invalidate future actions rather than hot-swap
|
||||
resources; Electron Main is the only hosted transport authority.
|
||||
- Evidence, reflections, or commitments affecting this task: existing workers are
|
||||
intentionally frozen, and the current installed binary may predate source changes;
|
||||
live PostgreSQL/OpenAI/production activation holds are unrelated to this client-side
|
||||
`409` and must not be presented as verified.
|
||||
- Files or modules likely involved: actual Pi prompt/extension-context/session evidence,
|
||||
then `electron/coding-plugins/registry.ts`, effective resolver/policy admission, Pi
|
||||
materialization/lifecycle seams, and their focused tests if the evidence points there.
|
||||
- Unknowns, stale docs, or conflicts: a paid live Provider call remains intentionally
|
||||
unexecuted. The client-side failure is independently reproducible at the frozen
|
||||
snapshot boundary and does not require spending Token Points to diagnose.
|
||||
|
||||
Gate result:
|
||||
- Passed. Concurrent ownership and Planning context are resolved; diagnostic work may
|
||||
proceed in this isolated worktree.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Live evidence from the reported Conversation proves that the parent worker contains
|
||||
the `makelore-web-search` Skill, the `makelore_web_search` tool, the official bundled
|
||||
Release `00000000-0000-4000-8000-000000000204`, account generation 1, and current
|
||||
runtime policy. The confirmed invocation alone returned
|
||||
`409 plugin_runtime_stale`; no resource-rebuild event occurred before it.
|
||||
- Ranked hypotheses were checked against the persisted Pi manifest/session, project
|
||||
selection, Agent assignment, session timestamps, extension-host context, and Main
|
||||
logs. Wrong child role, wrong Conversation/generation, an account epoch change, and a
|
||||
project/Release mutation were not supported. The remaining reachable path was the
|
||||
registry's invocation-time call to the full worker materializer.
|
||||
- Root cause: `CodingCapabilityRegistryImpl.invoke()` re-ran
|
||||
`EffectivePluginResolver.resolve()`. That path intentionally rejects a hosted Plugin
|
||||
whenever a subsequent Account Library request returns a trusted but stale snapshot,
|
||||
so one transient Library refresh invalidated a worker that had just been materialized.
|
||||
This happened before the hosted adapter could obtain the server-owned immutable
|
||||
Admission that actually gates account, runtime status, and exact Release dispatch.
|
||||
- Added a separate `resolveForInvocation()` contract. New workers still fail closed on
|
||||
stale hosted Library data. An already frozen worker may retain the last verified
|
||||
Library projection long enough to reach the typed hosted Admission; missing Library,
|
||||
account change, project disable, removed acquisition, suspension, incompatible or
|
||||
changed Release, unsupported policy, billing unavailability, and child-worker gates
|
||||
remain unchanged.
|
||||
- TDD evidence: the new resolver and registry regressions first failed because
|
||||
`resolveForInvocation` did not exist and the registry still called `resolve`; after
|
||||
the focused correction both pass.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm exec vitest run tests/unit/coding-plugin-effective-resolver.test.ts tests/unit/coding-capability-registry.test.ts`: 2 files / 19 tests passed.
|
||||
- `pnpm exec vitest run tests/unit/coding-plugin-effective-resolver.test.ts tests/unit/coding-capability-registry.test.ts tests/unit/hosted-admission.test.ts tests/unit/web-search-plugin-adapter.test.ts tests/unit/coding-plugin-composition.test.ts tests/unit/coding-product-services.test.ts`: 6 files / 55 tests passed.
|
||||
- `pnpm run typecheck`: passed.
|
||||
- Scoped ESLint for the two product and two regression-test files: passed.
|
||||
- `pnpm test`: 215 regular files / 1,786 passed / 2 skipped, followed by the
|
||||
single-worker pressure suite 1/1 passed. The first diagnostic run encountered two
|
||||
transient `spawn EBUSY` failures while the offline Electron dependency was settling;
|
||||
that exact real-process file then passed 3/3, and the clean full rerun passed.
|
||||
- `git diff --check`, `check_project_docs.py`, and task-aware
|
||||
`check_doc_drift.py`: passed.
|
||||
- A paid Web Search execution was not run; this task does not claim Provider, Token
|
||||
Point settlement, deployment, packaging, or production activation evidence.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- None recorded.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- None recorded.
|
||||
@@ -251,6 +251,23 @@ export class EffectivePluginResolver {
|
||||
constructor(private readonly options: EffectivePluginResolverOptions) {}
|
||||
|
||||
async resolve(input: EffectivePluginResolverInput): Promise<EffectivePluginSnapshot> {
|
||||
return this.resolveSnapshot(input, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revalidate an already frozen worker before invoking one of its tools.
|
||||
* A stale Library snapshot is still the last verified account projection;
|
||||
* the hosted adapter resolves a fresh server Admission for the exact frozen
|
||||
* Release before dispatch. New workers continue to require a current Library.
|
||||
*/
|
||||
async resolveForInvocation(input: EffectivePluginResolverInput): Promise<EffectivePluginSnapshot> {
|
||||
return this.resolveSnapshot(input, true);
|
||||
}
|
||||
|
||||
private async resolveSnapshot(
|
||||
input: EffectivePluginResolverInput,
|
||||
allowStaleHostedLibrary: boolean,
|
||||
): Promise<EffectivePluginSnapshot> {
|
||||
const definitions = await this.definitionRecords();
|
||||
const blockedMarketplacePlugins = marketplaceSkillConflicts(definitions);
|
||||
const assigned = normalizeIds(input.assignedSkillIds);
|
||||
@@ -342,7 +359,9 @@ export class EffectivePluginResolver {
|
||||
unavailableReasons.push(unavailable(definition.id, 'account_required', 'Marketplace account is required'));
|
||||
continue;
|
||||
}
|
||||
if (!library || (library.stale && definition.runtimeKind !== 'skill_only')) {
|
||||
if (!library || (library.stale
|
||||
&& definition.runtimeKind !== 'skill_only'
|
||||
&& !allowStaleHostedLibrary)) {
|
||||
unavailableReasons.push(unavailable(definition.id, 'library_unavailable', 'Marketplace Library is unavailable'));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -560,7 +560,7 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor
|
||||
&& candidate.capabilityId === tool.capabilityId
|
||||
&& candidate.operation === tool.operation
|
||||
));
|
||||
const currentSnapshot = await this.options.effectiveResolver.resolve({
|
||||
const currentSnapshot = await this.options.effectiveResolver.resolveForInvocation({
|
||||
projectId: input.context.projectId,
|
||||
projectPath: input.context.projectPath,
|
||||
assignedSkillIds: input.context.effectiveSnapshot.effectiveSkillIds,
|
||||
|
||||
@@ -273,6 +273,7 @@ describe('CodingCapabilityRegistry', () => {
|
||||
const resolve = vi.fn(async () => frozenSnapshot);
|
||||
const effectiveResolver = {
|
||||
resolve,
|
||||
resolveForInvocation: resolve,
|
||||
getSkillSources: vi.fn(async () => []),
|
||||
getPolicyState: vi.fn(() => hostedPolicy),
|
||||
getInstalledDefinition: vi.fn(async () => hostedDefinition),
|
||||
@@ -400,8 +401,10 @@ describe('CodingCapabilityRegistry', () => {
|
||||
const resolve = vi.fn(async ({ role }: { role: 'parent' | 'child' }) => (
|
||||
role === 'child' ? { ...disabled, unavailableReasons: [] } : current
|
||||
));
|
||||
const resolveForInvocation = vi.fn(async () => current);
|
||||
const effectiveResolver = {
|
||||
resolve,
|
||||
resolveForInvocation,
|
||||
getSkillSources: vi.fn(async () => [{
|
||||
id: 'web-search', pluginId: definition.id, packageRoot: 'C:/packages/web-search',
|
||||
directory: 'C:/packages/web-search/skills/web-search', entryPath: 'SKILL.md',
|
||||
@@ -461,6 +464,7 @@ describe('CodingCapabilityRegistry', () => {
|
||||
capability_id: 'web-search.search', operation: 'search',
|
||||
payload_schema: 'web-search.v1', billing: { status: 'settled', actual_points: '1.00' },
|
||||
});
|
||||
expect(resolveForInvocation).toHaveBeenCalledOnce();
|
||||
|
||||
current = disabled;
|
||||
const stale = await capabilityRegistry.invoke({
|
||||
@@ -497,6 +501,7 @@ describe('CodingCapabilityRegistry', () => {
|
||||
};
|
||||
const effectiveResolver = {
|
||||
resolve: vi.fn(async () => currentSnapshot),
|
||||
resolveForInvocation: vi.fn(async () => currentSnapshot),
|
||||
getSkillSources: vi.fn(async () => []),
|
||||
getPolicyState: vi.fn(() => policy),
|
||||
} as unknown as EffectivePluginResolver;
|
||||
@@ -509,7 +514,7 @@ describe('CodingCapabilityRegistry', () => {
|
||||
});
|
||||
|
||||
expect(result.details).toMatchObject({ success: false, code: 'plugin_not_enabled' });
|
||||
expect(effectiveResolver.resolve).toHaveBeenCalledWith(expect.objectContaining({
|
||||
expect(effectiveResolver.resolveForInvocation).toHaveBeenCalledWith(expect.objectContaining({
|
||||
projectId: context.projectId,
|
||||
projectPath: context.projectPath,
|
||||
role: 'parent',
|
||||
|
||||
@@ -322,6 +322,44 @@ describe('effective plugin resolver', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a frozen hosted worker usable through a transient stale Library refresh', async () => {
|
||||
const currentHostedLibrary = {
|
||||
...library(),
|
||||
items: [{ ...library().items[0], pluginId: serverDefinition.id }],
|
||||
};
|
||||
const getLibrary = vi.fn()
|
||||
.mockResolvedValueOnce(currentHostedLibrary)
|
||||
.mockResolvedValue({ ...currentHostedLibrary, stale: true });
|
||||
const hosted = createEffectivePluginResolver({
|
||||
definitions: [serverDefinition],
|
||||
getAccountBinding: () => binding,
|
||||
getLibrary,
|
||||
getInstalled: vi.fn(async () => installed(serverDefinition)),
|
||||
getEnabledPluginIds: vi.fn(async () => [serverDefinition.id]),
|
||||
policyClient: { getState: currentPolicy, refresh: vi.fn() },
|
||||
});
|
||||
const input = {
|
||||
projectId: 'project-a', projectPath: 'C:/project-a',
|
||||
assignedSkillIds: ['remote'], role: 'parent' as const,
|
||||
};
|
||||
|
||||
const frozen = await hosted.resolve(input);
|
||||
expect(frozen.toolDefinitions).toEqual([expect.objectContaining({ name: 'remote_read' })]);
|
||||
await expect(hosted.resolveForInvocation(input)).resolves.toMatchObject({
|
||||
accountSessionId: frozen.accountSessionId,
|
||||
pluginReleaseIds: frozen.pluginReleaseIds,
|
||||
toolDefinitions: [{ name: 'remote_read' }],
|
||||
runtimePolicies: [{ pluginId: serverDefinition.id, operation: 'read' }],
|
||||
unavailableReasons: [],
|
||||
});
|
||||
|
||||
await expect(hosted.resolve(input)).resolves.toMatchObject({
|
||||
toolDefinitions: [],
|
||||
runtimePolicies: [],
|
||||
unavailableReasons: [expect.objectContaining({ code: 'library_unavailable' })],
|
||||
});
|
||||
});
|
||||
|
||||
it('never exposes plugin resources to a child worker', async () => {
|
||||
const result = await resolver().resolve({
|
||||
projectId: 'project-a', projectPath: 'C:/project-a', assignedSkillIds: ['notes'], role: 'child',
|
||||
|
||||
Reference in New Issue
Block a user