fix(coding): recover provider context failures
This commit is contained in:
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user