diff --git a/.project-docs/30-worklog/tasks/20260826-fix-pi-model-provider-8d4c2a71.md b/.project-docs/30-worklog/tasks/20260826-fix-pi-model-provider-8d4c2a71.md new file mode 100644 index 0000000..1252c4f --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260826-fix-pi-model-provider-8d4c2a71.md @@ -0,0 +1,170 @@ +# Task: Fix Pi first-model and Provider context failures + +## Identity + +- Task ID: 20260826-fix-pi-model-provider-8d4c2a71 +- Mode: Feature +- Branch: codex/20260826-fix-pi-model-provider-8d4c2a71-fix-pi-model-provider +- Worktree: D:\Datas\OthersProjects\makelore-fix-pi-model-provider-8d4c2a71 +- Base commit: 9f05e2d7e194b3ae3507c019de53e437aaed9458 +- Owner: codex-root +- Status: In Progress + +## Scope + +- Fix the production ordering for an unresolved Conversation's first model + selection: validate the selected managed model, persist resolved product + metadata, and only then prepare the Pi worker/session. Preserve the existing + active-run-safe `runtime.setModel()` path for already-resolved Conversations. +- Add a production-shaped regression whose runtime rejects every unresolved + prepare, so the InMemory runtime can no longer hide the ordering defect. +- Classify the exact captured Works Square gateway failure "did not return + one-api user context" at the Main-owned local AI proxy boundary as a + Provider credential/context failure. Preserve the upstream body, avoid + generic Pi automatic retry of the deterministic `502`, and reuse the + existing single credential refresh/reopen boundary without replaying a + mutation. +- Replace the misleading generic English "local Agent retry failed" projection + with a fixed Provider-neutral Chinese retry failure message while retaining + content redaction. +- Rebuild and verify the Windows package and final packaged proof after the + product-code correction; durably bind the candidate/artifact/runtime/test + identity required by the prior planner review. + +## Intent And Constraints + +- The installed incident is confirmed to be a Works/one-api user-context + rejection, not a Pi worker crash and not the unresolved-first-model defect. + These are two independent defects fixed in one bounded correction because + both block acceptance of the same candidate. +- Do not invent a new Provider authentication protocol or forward the local + Host `Authorization` token upstream. Existing evidence shows the same proxy + contract worked before the incident; only the exact observed structured + failure may be reclassified unless tests or source contracts prove more. +- Keep Provider refresh bounded to the existing one refresh/reopen attempt. + Never replay an accepted or uncertain mutation, and never add another Pi + restart/watchdog/fallback path. +- Preserve Pi `0.84.2`, the hard cutover, Conversation target isolation, + selected-account secret scoping, redaction, and Main ownership of Provider + and runtime lifecycle. +- Real Provider remains `Explicitly Waived / Accepted Risk` with + `realTurnVerified=false`. A loopback or captured-response regression is not + a real Provider Pass. macOS and native non-WSL Linux remain unverified. +- No external Works account, gateway mapping, installed user data, or running + application is mutated by this feature task. If the server mapping remains + invalid, the client must expose an actionable Provider failure but cannot + manufacture a successful turn. + +## Project Context Loaded + +- `.project-docs/10-overview/read-before-planning.md` +- `.project-docs/10-overview/project-positioning.md` +- `.project-docs/10-overview/current-state.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-rules/business-rules.md` +- `.project-docs/40-rules/success-criteria.md` +- `.project-docs/60-contracts/commitments.md` +- `.project-docs/60-contracts/stale-items.md` +- `.project-docs/00-governance/memory-index.md` +- `.project-docs/00-governance/decision-index.md` +- `.project-docs/00-governance/evidence-index.md` +- `.project-docs/00-governance/reflection-index.md` +- `.project-docs/00-governance/planning-gate.md` +- `.project-docs/30-worklog/proposals/20260825-pi-runtime-resilience-proposal.md` +- Peer scopes inspected: the completed background-run implementation, its + planner review, and the installed-package diagnosis. This task owns only the + two accepted corrections and their regenerated Windows proof; it does not + reopen retained-correlation/background-lease design or modify the read-only + diagnosis records. +- All registry peers still marked `planning` were inspected through their task + records. Eight retain placeholder/undefined scope and therefore provide + unknown coordination state, but none claims this worktree or a concrete Pi + Provider/conversation-service code path. The one concrete E2E peer is scoped + to an AI Design test. The existing `main` integration task owns only the old + OpenCode model-switch reconciliation and the canonical integration lock; it + does not conflict with feature implementation here, but it must be resolved + before any later merge to `main`. +- Planning Gate: **Passed**. Ownership matches this task's feature worktree and + branch; no concrete semantic conflict blocks the bounded correction. + +## Plan + +1. Add failing regressions for strict unresolved preparation and the exact + captured Works user-context response; confirm both fail for the expected + contract reason. +2. Make the minimal ordering and Provider-classification changes, plus the + redacted Provider-neutral retry message, then rerun the focused feedback + loop. +3. Run typecheck, scoped/full lint and unit tests, `build:vite`, applicable + Windows Electron E2E, Windows package/artifact/Pi verifiers, and final + packaged proof. Record exact candidate and artifact identity. +4. Complete the task documentation gate and commit only owned product/tests/ + task-record changes. Integration to `main` remains a separate Integration + Gate after the feature candidate is accepted. + +## Outcome + +- `CodingConversationService.setModel()` now keeps the already-resolved path + unchanged (`prepare -> authoritative Snapshot guard -> runtime.setModel -> + persist`) while the unresolved path performs `validate -> persist resolved + metadata -> initial prepare`. A strict runtime that rejects unresolved input + reproduces the old `CODING_MIGRATION_MODEL_REQUIRED` failure and passes only + after the ordering correction. +- The Main-owned AI proxy recognizes only the captured Works Square + user-context failure shape. It expires the cached gateway credential, + preserves the upstream JSON body, maps the retryable upstream `502` to a + non-retryable `401`, and does not replay the current Provider mutation. The + next user retry obtains a fresh gateway credential through the existing + credential service. +- Pi projection now terminalizes a non-retrying assistant Provider error on + `agent_end`, preserves the failure through `agent_settled`, and classifies + both fail-fast and exhausted-retry forms of the observed Works failure as + `CODING_PROVIDER_AUTH_REQUIRED`. Raw Provider/request text is not projected. + Other exhausted Provider retries use a fixed Chinese Provider-neutral + message instead of blaming a local Agent crash. +- This client correction cannot repair a persistently invalid Works backend + account mapping. It makes one fresh-credential retry possible and attributes + any continuing failure to the correct Provider boundary. + +## Verification + +- Regression red phase: the three new production-shaped tests failed on the + old implementation with unresolved prepare, forwarded `502`, and an + incorrectly completed run. +- Focused related tests: 5 files / 54 tests passed before the exhausted-retry + addition; final projector file 12/12 passed. +- `pnpm run typecheck`: passed. +- Scoped ESLint: passed. +- `pnpm test`: 181 functional files / 1540 passed / 2 skipped, plus REN-008 + pressure 1/1 passed. The final added projector assertion is covered by a + subsequent isolated 12/12 pass and will be included in the clean-candidate + rerun. +- `pnpm run lint:check`: 0 errors / 5 pre-existing warnings. +- `pnpm run build:vite`: passed. +- `pnpm run test:electron:windows`: 2 files / 4 tests passed. +- A dirty-worktree staging/package smoke completed after the GitHub UV download + timed out twice; exact verified uv 0.10.0 binaries were reused from the prior + accepted Windows candidate. This smoke is not final artifact evidence because + packaged Git metadata still points at the base commit. A clean committed + rebuild and all artifact/final-ASAR verifiers remain required. + +## Follow-ups + +- Commit the implementation candidate, rebuild from that clean commit, and run + Windows artifact/Pi/final-ASAR verification with a self-bound structured + report before completing this feature task. +- After feature acceptance, use a separate Integration Gate to resolve the old + `main` integration lock, reconcile canonical project memory, and merge. Do + not conflate this feature branch with integration ownership. + +## Promotion Candidates + +- Target canonical document: Pi runtime/provider failure contract. + Proposal: record that deterministic Works user-context failures expire the + cached gateway credential, fail fast without replay, and remain Provider + failures through Renderer projection rather than being relabeled as local + worker crashes. Evidence: captured installed-package trace plus strict proxy + and projector regressions. Human confirmation required during Integration + Gate: yes. diff --git a/electron/api/routes/ai-proxy.ts b/electron/api/routes/ai-proxy.ts index 66415b0..ce59208 100644 --- a/electron/api/routes/ai-proxy.ts +++ b/electron/api/routes/ai-proxy.ts @@ -9,7 +9,10 @@ import { import { proxyAwareFetch } from '../../utils/proxy-fetch'; import { logger } from '../../utils/logger'; import { getAIGatewayErrorKind } from '../../../shared/ai-gateway-error-kind'; -import { isAIGatewayUpstreamSaturated } from '../../../shared/ai-gateway-error-details'; +import { + isAIGatewayUpstreamSaturated, + isAIGatewayUserContextMissing, +} from '../../../shared/ai-gateway-error-details'; const AI_PROXY_PREFIX = '/api/ai-proxy/v1'; const MAX_ERROR_LOG_MESSAGE_LENGTH = 600; @@ -80,6 +83,9 @@ function isExpiredGatewayTokenResponse(status: number, bodyText: string): boolea } function getForwardedOneApiStatus(status: number, bodyText: string): number { + if (status === 502 && isAIGatewayUserContextMissing(bodyText)) { + return 401; + } if (status === 429 && getAIGatewayErrorKind(bodyText) === 'quota_exhausted') { return 402; } @@ -282,6 +288,9 @@ export async function handleAiProxyRoutes( upstream = upstreamResult.response; targetUrl = upstreamResult.targetUrl; } else { + if (isAIGatewayUserContextMissing(bodyText)) { + markWorksSquareAIGatewayCredentialExpired(); + } logNonSuccessOneApiResponseFromBody(upstream, targetUrl, bodyText); await writeBufferedResponse(res, upstream, bodyText, getForwardedOneApiStatus(upstream.status, bodyText)); return true; @@ -290,6 +299,9 @@ export async function handleAiProxyRoutes( if (!upstream.ok) { const bodyText = await upstream.text(); + if (isAIGatewayUserContextMissing(bodyText)) { + markWorksSquareAIGatewayCredentialExpired(); + } logNonSuccessOneApiResponseFromBody(upstream, targetUrl, bodyText); await writeBufferedResponse(res, upstream, bodyText, getForwardedOneApiStatus(upstream.status, bodyText)); return true; diff --git a/electron/coding-runtime/conversation-service.ts b/electron/coding-runtime/conversation-service.ts index 15e6dfd..8103cf2 100644 --- a/electron/coding-runtime/conversation-service.ts +++ b/electron/coding-runtime/conversation-service.ts @@ -443,13 +443,13 @@ export class CodingConversationService { try { selected = await this.runtime.validateModel(model); } catch (error) { runtimeError(error); } - await this.ensurePrepared(conversationId); - let snapshot: ConversationSnapshot; - try { - snapshot = await this.runtime.getSnapshot(conversationId); - } catch (error) { runtimeError(error); } - this.assertSnapshotAllowsMutation(snapshot); if (conversation.modelResolution === 'resolved' && conversation.model) { + await this.ensurePrepared(conversationId); + let snapshot: ConversationSnapshot; + try { + snapshot = await this.runtime.getSnapshot(conversationId); + } catch (error) { runtimeError(error); } + this.assertSnapshotAllowsMutation(snapshot); let state: ConversationModelState; try { state = await this.runtime.setModel({ diff --git a/electron/coding-runtime/pi/event-projector.ts b/electron/coding-runtime/pi/event-projector.ts index ab747ae..b1f4559 100644 --- a/electron/coding-runtime/pi/event-projector.ts +++ b/electron/coding-runtime/pi/event-projector.ts @@ -6,6 +6,7 @@ import type { ConversationToolNode, PublicUsage, } from '../contracts'; +import { isAIGatewayUserContextMissing } from '../../../shared/ai-gateway-error-details'; import type { PiRpcEvent } from './rpc-client'; import { subagentDetailsOfResult } from '../subagent-protocol'; import { @@ -44,6 +45,17 @@ function retryFailure(message: string) { }; } +function providerFailure(message: unknown) { + if (typeof message === 'string' && isAIGatewayUserContextMissing(message)) { + return { + code: 'CODING_PROVIDER_AUTH_REQUIRED' as const, + message: '模型服务身份上下文无效,请重试;若仍失败请重新登录。', + recoverable: true, + }; + } + return retryFailure('模型服务请求失败,请稍后重试。'); +} + function asRecord(value: unknown): Record | null { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record @@ -282,7 +294,7 @@ export class PiEventProjector { ...run, status: 'error', terminalReason: 'failed', - error: retryFailure('The local Agent retry failed'), + error: providerFailure(event.finalError), }, }]; } @@ -459,6 +471,25 @@ export class PiEventProjector { ]; } + if (event.type === 'agent_end' && event.willRetry === false && Array.isArray(event.messages)) { + const assistant = [...event.messages] + .reverse() + .map(asRecord) + .find((message) => message?.role === 'assistant'); + if (assistant?.stopReason === 'error') { + return [{ + op: 'run.state', + run: { + ...snapshot.run, + status: 'error', + terminalReason: 'failed', + error: providerFailure(assistant.errorMessage), + }, + }]; + } + return []; + } + if (event.type === 'extension_ui_request' && typeof event.id === 'string' && typeof event.method === 'string' diff --git a/shared/ai-gateway-error-details.ts b/shared/ai-gateway-error-details.ts index 98fb2ac..15e3ca6 100644 --- a/shared/ai-gateway-error-details.ts +++ b/shared/ai-gateway-error-details.ts @@ -4,3 +4,8 @@ export function isAIGatewayUpstreamSaturated(message: string | null | undefined) return normalized.includes('当前分组上游负载已饱和') || (normalized.includes('upstream') && normalized.includes('saturat')); } + +export function isAIGatewayUserContextMissing(message: string | null | undefined): boolean { + const normalized = message?.trim().toLowerCase() ?? ''; + return normalized.includes('works square ai gateway did not return one-api user context'); +} diff --git a/tests/unit/ai-proxy-routes.test.ts b/tests/unit/ai-proxy-routes.test.ts index f0c4d9e..dfa98a6 100644 --- a/tests/unit/ai-proxy-routes.test.ts +++ b/tests/unit/ai-proxy-routes.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { handleAiProxyRoutes } from '@electron/api/routes/ai-proxy'; import { clearWorksSquareAIGatewayCredential, + getWorksSquareAIGatewaySnapshot, seedWorksSquareAIGatewayCredential, } from '@electron/services/works-square-ai-gateway'; import { @@ -339,6 +340,42 @@ describe('ai proxy routes', () => { expect(response.body()).toContain('works_square_gateway_authorize_failed'); }); + it('fails fast and expires the gateway credential when Works cannot resolve one-api user context', async () => { + seedWorksSquareAIGatewayCredential({ + accessToken: 'ws-ai-token', + expiresIn: 3600, + oneApiBaseUrl: 'https://one-api.example.com/v1', + }); + const body = JSON.stringify({ + error: { + message: 'works square AI gateway did not return one-api user context', + type: 'one_api_error', + }, + }); + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response(body, { + status: 502, + headers: { 'content-type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + + await handleAiProxyRoutes( + createRequest('POST', { model: 'qwen3.7-max', messages: [{ role: 'user', content: 'secret prompt' }] }), + response.res, + new URL('http://127.0.0.1:13210/api/ai-proxy/v1/chat/completions'), + {} as never, + ); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(response.statusCode).toBe(401); + expect(response.body()).toBe(body); + expect(getWorksSquareAIGatewaySnapshot()).toMatchObject({ expiresAt: 0 }); + expect(JSON.stringify(loggerWarnMock.mock.calls)).not.toContain('secret prompt'); + expect(JSON.stringify(loggerWarnMock.mock.calls)).not.toContain('ws-ai-token'); + }); + it('maps explicit upstream group saturation to a non-retryable response status', async () => { seedWorksSquareAIGatewayCredential({ accessToken: 'ws-ai-token', diff --git a/tests/unit/coding-core-routes.test.ts b/tests/unit/coding-core-routes.test.ts index a7cd0c6..b28cc80 100644 --- a/tests/unit/coding-core-routes.test.ts +++ b/tests/unit/coding-core-routes.test.ts @@ -30,6 +30,7 @@ import { import type { ConversationSnapshot, ConversationPatchEnvelope, + PrepareConversationInput, PromptConversationInput, } from '../../electron/coding-runtime/contracts'; import { archivePiConversationSession } from '../../electron/coding-runtime/pi/resource-loader'; @@ -175,7 +176,22 @@ describe('PI-100 coding core Host contract', () => { mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [], }, }); - const runtime = new InMemoryConversationRuntime(); + class StrictResolvedModelRuntime extends InMemoryConversationRuntime { + readonly prepareInputs: PrepareConversationInput[] = []; + + override async prepare(input: PrepareConversationInput) { + this.prepareInputs.push(structuredClone(input)); + if (input.model.modelResolution !== 'resolved' || !input.model.model) { + throw new CodingRuntimeContractError( + 'CODING_MIGRATION_MODEL_REQUIRED', + 'A resolved model is required before preparation', + true, + ); + } + return await super.prepare(input); + } + } + const runtime = new StrictResolvedModelRuntime(); const projects = new CodingProjectService(store); const conversations = new CodingConversationService(projects, runtime); const conversation = await conversations.createConversation({ agentId: 'builder', title: 'Resolve me' }); @@ -189,6 +205,10 @@ describe('PI-100 coding core Host contract', () => { conversationId: conversation.id, model: { model: MODEL, modelResolution: 'resolved' }, })); + expect(runtime.prepareInputs).toHaveLength(1); + expect(runtime.prepareInputs.every(({ model }) => ( + model.modelResolution === 'resolved' && model.model !== null + ))).toBe(true); await expect(projects.conversationStore(root).get(conversation.id)).resolves.toMatchObject({ model: MODEL, modelResolution: 'resolved', diff --git a/tests/unit/pi-event-projector.test.ts b/tests/unit/pi-event-projector.test.ts index 9366bc5..825a24a 100644 --- a/tests/unit/pi-event-projector.test.ts +++ b/tests/unit/pi-event-projector.test.ts @@ -435,6 +435,71 @@ describe('Pi event projector', () => { })); }); + it('projects a non-retryable Works user-context failure as Provider auth required', async () => { + const projector = new PiEventProjector({ createId: () => 'provider-error-a' }); + let snapshot = emptySnapshot(); + const providerFailure = { + role: 'assistant', + content: [], + stopReason: 'error', + errorMessage: '401: works square AI gateway did not return one-api user context; secret request detail', + timestamp: 10, + }; + + snapshot = apply(snapshot, await projector.project(snapshot, { + type: 'message_end', + message: providerFailure, + })); + snapshot = apply(snapshot, await projector.project(snapshot, { + type: 'agent_end', + messages: [providerFailure], + willRetry: false, + })); + snapshot = apply(snapshot, await projector.project(snapshot, { type: 'agent_settled' })); + + expect(snapshot.run).toMatchObject({ + status: 'idle', + terminalReason: 'failed', + error: { + code: 'CODING_PROVIDER_AUTH_REQUIRED', + message: '模型服务身份上下文无效,请重试;若仍失败请重新登录。', + recoverable: true, + }, + }); + expect(JSON.stringify(snapshot)).not.toContain('secret request detail'); + }); + + it('keeps an exhausted Works user-context retry redacted and Provider-owned', async () => { + const projector = new PiEventProjector({ createId: () => 'provider-retry-error-a' }); + let snapshot = emptySnapshot(); + + snapshot = apply(snapshot, await projector.project(snapshot, { + type: 'auto_retry_start', + attempt: 3, + maxAttempts: 3, + delayMs: 1_000, + errorMessage: 'sensitive earlier failure', + })); + snapshot = apply(snapshot, await projector.project(snapshot, { + type: 'auto_retry_end', + success: false, + attempt: 3, + finalError: '502: works square AI gateway did not return one-api user context; secret final detail', + })); + snapshot = apply(snapshot, await projector.project(snapshot, { type: 'agent_settled' })); + + expect(snapshot.run).toMatchObject({ + status: 'idle', + terminalReason: 'failed', + error: { + code: 'CODING_PROVIDER_AUTH_REQUIRED', + message: '模型服务身份上下文无效,请重试;若仍失败请重新登录。', + recoverable: true, + }, + }); + expect(JSON.stringify(snapshot)).not.toContain('secret'); + }); + it('maps all summary-retry events and keeps exhausted compaction failure redacted', async () => { const ids = ['compaction-a', 'summary-retry-boundary-a']; const projector = new PiEventProjector({ createId: () => ids.shift() as string });