fix(coding): recover provider context failures

This commit is contained in:
2026-08-26 14:42:01 +08:00
parent 9f05e2d7e1
commit a09826676e
8 changed files with 349 additions and 9 deletions

View File

@@ -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.

View File

@@ -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;

View File

@@ -443,13 +443,13 @@ export class CodingConversationService {
try {
selected = await this.runtime.validateModel(model);
} catch (error) { runtimeError(error); }
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);
if (conversation.modelResolution === 'resolved' && conversation.model) {
let state: ConversationModelState;
try {
state = await this.runtime.setModel({

View File

@@ -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<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
@@ -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'

View File

@@ -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');
}

View File

@@ -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',

View File

@@ -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',

View File

@@ -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 });