fix: classify codex steer proxy failures
This commit is contained in:
+26
-2
@@ -23,7 +23,28 @@ import { isHelpToken, rootHelp, serverHelp, sshHelp, staticNamespaceHelp } from
|
||||
|
||||
const remoteOptions = extractRemoteCliOptions(process.argv.slice(2));
|
||||
const args = remoteOptions.args;
|
||||
const commandName = args.join(" ") || "help";
|
||||
const commandName = displayCommandName(args);
|
||||
|
||||
function displayCommandName(parts: string[]): string {
|
||||
if (parts.length === 0) return "help";
|
||||
if (parts[0] === "codex" && parts[1] === "steer" && parts[2] !== undefined) {
|
||||
const shown = ["codex", "steer", parts[2]];
|
||||
const hasPromptFile = parts.includes("--prompt-file") || parts.includes("--file");
|
||||
const hasPromptStdin = parts.includes("--prompt-stdin") || parts.includes("--stdin");
|
||||
if (!hasPromptFile && !hasPromptStdin) shown.push("<prompt:redacted>");
|
||||
for (let index = 3; index < parts.length; index += 1) {
|
||||
const part = parts[index] ?? "";
|
||||
if (!part.startsWith("--")) continue;
|
||||
shown.push(part);
|
||||
if (part === "--prompt-file" || part === "--file") {
|
||||
shown.push(parts[index + 1] ?? "<missing>");
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return shown.join(" ");
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function numberOption(name: string, defaultValue: number): number {
|
||||
const index = args.indexOf(name);
|
||||
@@ -252,7 +273,10 @@ async function main(): Promise<void> {
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
emitJson(commandName, await runCodeQueueCommand(config, args.slice(1)));
|
||||
const result = await runCodeQueueCommand(config, args.slice(1));
|
||||
const ok = (result as { ok?: unknown }).ok !== false;
|
||||
emitJson(commandName, result, ok);
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
|
||||
import { writeFileSync, unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { codexSteerTaskForTest } from "./src/code-queue";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -48,15 +49,35 @@ function assertDryRunPrompt(response: JsonRecord, expectedText: string): void {
|
||||
assertCondition(response.ok === true, "CLI dry-run should succeed", response);
|
||||
const data = nestedRecord(response.data, []);
|
||||
assertCondition(data.dryRun === true, "dry-run response should expose dryRun=true", data);
|
||||
const request = nestedRecord(response.data, ["request"]);
|
||||
assertCondition(request.method === "POST", "dry-run should expose request method", request);
|
||||
assertCondition(request.path === "/api/tasks/codex_test_task/steer", "dry-run should expose request path", request);
|
||||
assertCondition(request.stableProxyPath === "/api/microservices/code-queue/proxy/api/tasks/codex_test_task/steer", "dry-run should expose stable proxy path", request);
|
||||
const prompt = nestedRecord(response.data, ["request", "body", "prompt"]);
|
||||
assertCondition(prompt.text === expectedText, "dry-run prompt text mismatch", prompt);
|
||||
assertCondition(prompt.chars === expectedText.length, "dry-run prompt char count mismatch", prompt);
|
||||
assertCondition(prompt.truncated === false, "dry-run prompt must not truncate", prompt);
|
||||
const bodySummary = nestedRecord(response.data, ["request", "bodySummary"]);
|
||||
assertCondition(bodySummary.promptChars === expectedText.length, "dry-run should expose body prompt char count", bodySummary);
|
||||
const commands = nestedRecord(response.data, ["commands"]);
|
||||
assertCondition(String(commands.rawProxy || "").includes("microservice proxy code-queue /api/tasks/codex_test_task/steer --method POST"), "dry-run should expose raw proxy equivalent", commands);
|
||||
}
|
||||
|
||||
function assertReason(result: unknown, reason: string, status: number | null): void {
|
||||
const data = nestedRecord({ data: result }, ["data"]);
|
||||
assertCondition(data.ok === false, "classified steer failure should be ok=false", data);
|
||||
const diagnostics = nestedRecord(data, ["diagnostics"]);
|
||||
assertCondition(diagnostics.reason === reason, "unexpected steer failure reason", diagnostics);
|
||||
assertCondition(diagnostics.status === status, "unexpected steer failure status", diagnostics);
|
||||
assertCondition(typeof diagnostics.retryable === "boolean", "diagnostics should expose retryable boolean", diagnostics);
|
||||
assertCondition(Array.isArray(diagnostics.recommendedCrossChecks), "diagnostics should expose cross-check commands", diagnostics);
|
||||
}
|
||||
|
||||
export function runCodeQueueCliSteerContract(): JsonRecord {
|
||||
const positional = runCli(["codex", "steer", "codex_test_task", "correct the running task", "--dry-run"]);
|
||||
assertDryRunPrompt(positional.json ?? {}, "correct the running task");
|
||||
assertCondition(String(positional.json?.command || "").includes("<prompt:redacted>"), "outer command should redact positional steer prompt", positional.json ?? {});
|
||||
assertCondition(!String(positional.json?.command || "").includes("correct the running task"), "outer command must not echo positional steer prompt", positional.json ?? {});
|
||||
|
||||
const stdin = runCli(["codex", "steer", "codex_test_task", "--prompt-stdin", "--dry-run"], "stdin steer prompt\n");
|
||||
assertDryRunPrompt(stdin.json ?? {}, "stdin steer prompt\n");
|
||||
@@ -85,6 +106,51 @@ export function runCodeQueueCliSteerContract(): JsonRecord {
|
||||
const usage = stringArray(nestedRecord(help.json?.data, []).usage);
|
||||
assertCondition(usage.some((line) => line.includes("codex steer <taskId>")), "codex help should list steer", { usage });
|
||||
|
||||
let dryRunFetchCount = 0;
|
||||
const dryRunDirect = codexSteerTaskForTest("direct_task", ["do not send", "--dry-run"], () => {
|
||||
dryRunFetchCount += 1;
|
||||
return { ok: true, status: 200, body: { ok: true } };
|
||||
});
|
||||
assertCondition(dryRunFetchCount === 0, "dry-run must not call stable proxy helper", { dryRunFetchCount, dryRunDirect });
|
||||
|
||||
const longPrompt = `${"x".repeat(480)}-tail-secret-marker`;
|
||||
const longDryRun = codexSteerTaskForTest("direct_task", [longPrompt, "--dry-run"], () => {
|
||||
throw new Error("dry-run should not fetch");
|
||||
}) as JsonRecord;
|
||||
const longPreview = nestedRecord(longDryRun, ["request", "body", "prompt"]);
|
||||
assertCondition(longPreview.truncated === true, "long dry-run prompt should be truncated", longPreview);
|
||||
assertCondition(!String(longPreview.text || "").includes("tail-secret-marker"), "long dry-run must not leak prompt tail", longPreview);
|
||||
|
||||
let fetchPath = "";
|
||||
let fetchMethod = "";
|
||||
let fetchPrompt = "";
|
||||
const success = codexSteerTaskForTest("direct_task", ["send this"], (path, init) => {
|
||||
fetchPath = path;
|
||||
fetchMethod = String(init?.method || "");
|
||||
fetchPrompt = String((init?.body as JsonRecord | undefined)?.prompt || "");
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
ok: true,
|
||||
task: { id: "direct_task", status: "running", prompt: "p" },
|
||||
queue: { activeTaskIds: ["direct_task"] },
|
||||
},
|
||||
};
|
||||
}) as JsonRecord;
|
||||
assertCondition(fetchPath === "/api/microservices/code-queue/proxy/api/tasks/direct_task/steer", "non-dry-run should use stable proxy path", { fetchPath });
|
||||
assertCondition(fetchMethod === "POST", "non-dry-run should POST", { fetchMethod });
|
||||
assertCondition(fetchPrompt === "send this", "non-dry-run should send raw prompt in body", { fetchPrompt });
|
||||
assertCondition(nestedRecord(success, ["steer"]).accepted === true, "successful steer should report accepted=true", success);
|
||||
|
||||
assertReason(codexSteerTaskForTest("direct_task", ["p"], () => ({ ok: false, exitCode: 1, stderrTail: "Cannot connect to the Docker daemon" })), "backend-core-unreachable", null);
|
||||
assertReason(codexSteerTaskForTest("direct_task", ["p"], () => ({ ok: false, status: 404, body: { ok: false, error: "microservice not found: code-queue" } })), "code-queue-microservice-unregistered", 404);
|
||||
assertReason(codexSteerTaskForTest("direct_task", ["p"], () => ({ ok: false, status: 401, body: { ok: false, error: "unauthorized" } })), "proxy-unauthorized", 401);
|
||||
assertReason(codexSteerTaskForTest("direct_task", ["p"], () => ({ ok: false, status: 404, body: { ok: false, error: "proxy route not found", path: "/api/microservices/code-queue/proxy/api/tasks/direct_task/steer" } })), "proxy-404", 404);
|
||||
assertReason(codexSteerTaskForTest("direct_task", ["p"], () => ({ ok: false, status: 404, body: { ok: false, error: "task not found" } })), "steer-endpoint-404", 404);
|
||||
assertReason(codexSteerTaskForTest("direct_task", ["p"], () => ({ ok: false, status: 409, body: { ok: false, error: "task does not have an active steerable turn" } })), "upstream-runtime-rejected", 409);
|
||||
assertReason(codexSteerTaskForTest("direct_task", ["p"], () => ({ ok: false, status: 504, body: { ok: false, error: "provider HTTP tunnel timed out or disconnected", stage: "http-tunnel-wait" } })), "stable-proxy-failed", 504);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
checks: [
|
||||
@@ -94,6 +160,11 @@ export function runCodeQueueCliSteerContract(): JsonRecord {
|
||||
"duplicate prompt source failure",
|
||||
"unsupported option failure",
|
||||
"codex help lists steer",
|
||||
"outer command redacts positional steer prompt",
|
||||
"dry-run does not call stable proxy helper",
|
||||
"dry-run prompt preview is bounded",
|
||||
"non-dry-run uses stable proxy helper",
|
||||
"steer failure classification is JSON-consumable",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
+162
-5
@@ -1,6 +1,7 @@
|
||||
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
|
||||
import { coreInternalFetch } from "./microservices";
|
||||
import { previewJson } from "./preview";
|
||||
|
||||
const defaultToolLimit = 8;
|
||||
const defaultTraceLimit = 80;
|
||||
@@ -9,6 +10,7 @@ const defaultOutputLimit = 20;
|
||||
const defaultTextPreviewChars = 12_000;
|
||||
const defaultTasksLimit = 20;
|
||||
const maxTasksLimit = 100;
|
||||
const steerPromptPreviewChars = 320;
|
||||
const submitLockWaitMs = 60_000;
|
||||
const submitLockPollMs = 250;
|
||||
const submitLockStaleMs = 120_000;
|
||||
@@ -58,6 +60,30 @@ interface CodexSteerOptions {
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
type CodexSteerFailureReason =
|
||||
| "backend-core-unreachable"
|
||||
| "code-queue-microservice-unregistered"
|
||||
| "proxy-unauthorized"
|
||||
| "proxy-404"
|
||||
| "steer-endpoint-404"
|
||||
| "upstream-runtime-rejected"
|
||||
| "stable-proxy-failed"
|
||||
| "invalid-proxy-response";
|
||||
|
||||
interface ClassifiedCodexSteerError {
|
||||
reason: CodexSteerFailureReason;
|
||||
scope: "backend-core" | "stable-proxy" | "code-queue-runtime" | "unknown";
|
||||
status: number | null;
|
||||
exitCode: number | null;
|
||||
retryable: boolean;
|
||||
message: string;
|
||||
requestPath: string;
|
||||
stableProxyPath: string;
|
||||
upstreamBodyPreview: unknown;
|
||||
rawProxyEquivalent: string;
|
||||
recommendedCrossChecks: string[];
|
||||
}
|
||||
|
||||
interface CompactTaskMutationResponseOptions {
|
||||
fullPrompt?: boolean;
|
||||
}
|
||||
@@ -129,6 +155,10 @@ function codeQueueProxyPath(path: string): string {
|
||||
return `${codeQueueProxyPrefix}${path}`;
|
||||
}
|
||||
|
||||
function codeQueueProxyEquivalentCommand(targetPath: string, bodyJson: string): string {
|
||||
return `bun scripts/cli.ts microservice proxy code-queue ${targetPath} --method POST --body-json '${bodyJson}'`;
|
||||
}
|
||||
|
||||
function nonNegativeIntegerEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw.trim().length === 0) return fallback;
|
||||
@@ -285,6 +315,103 @@ function unwrapCodexResponse(response: unknown): { upstream: { ok: unknown; stat
|
||||
return { upstream: { ok: record.ok, status: record.status }, body };
|
||||
}
|
||||
|
||||
function responseStatus(response: Record<string, unknown> | null): number | null {
|
||||
const value = response?.status;
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function responseExitCode(response: Record<string, unknown> | null): number | null {
|
||||
const value = response?.exitCode ?? response?.commandExitCode;
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function responseBody(response: Record<string, unknown> | null): Record<string, unknown> | null {
|
||||
return asRecord(response?.body);
|
||||
}
|
||||
|
||||
function responseErrorMessage(response: Record<string, unknown> | null): string {
|
||||
const body = responseBody(response);
|
||||
const bodyError = body?.error;
|
||||
if (typeof bodyError === "string" && bodyError.length > 0) return bodyError;
|
||||
if (typeof response?.codeQueueObservationNote === "string") return response.codeQueueObservationNote;
|
||||
if (typeof response?.stderrTail === "string" && response.stderrTail.length > 0) return response.stderrTail;
|
||||
if (typeof response?.commandStderrTail === "string" && response.commandStderrTail.length > 0) return response.commandStderrTail;
|
||||
return "Code Queue steer request failed";
|
||||
}
|
||||
|
||||
function classifySteerFailure(response: unknown, targetPath: string, stableProxyPath: string, rawProxyEquivalent: string): ClassifiedCodexSteerError {
|
||||
const record = asRecord(response);
|
||||
const status = responseStatus(record);
|
||||
const exitCode = responseExitCode(record);
|
||||
const body = responseBody(record);
|
||||
const message = responseErrorMessage(record);
|
||||
const lowerMessage = message.toLowerCase();
|
||||
const bodyPath = typeof body?.path === "string" ? body.path : "";
|
||||
const bodyStage = typeof body?.stage === "string" ? body.stage : "";
|
||||
const bodyServiceId = typeof body?.serviceId === "string" ? body.serviceId : "";
|
||||
|
||||
let reason: CodexSteerFailureReason = "invalid-proxy-response";
|
||||
let scope: ClassifiedCodexSteerError["scope"] = "unknown";
|
||||
let retryable = false;
|
||||
|
||||
if (record === null || status === null && exitCode !== null) {
|
||||
reason = "backend-core-unreachable";
|
||||
scope = "backend-core";
|
||||
retryable = true;
|
||||
} else if (status === 401 || status === 403) {
|
||||
reason = "proxy-unauthorized";
|
||||
scope = "stable-proxy";
|
||||
retryable = false;
|
||||
} else if (status === 404 && /microservice not found: code-queue/u.test(lowerMessage)) {
|
||||
reason = "code-queue-microservice-unregistered";
|
||||
scope = "stable-proxy";
|
||||
retryable = false;
|
||||
} else if (status === 404 && (lowerMessage === "task not found" || lowerMessage === "not found" || bodyPath === targetPath || bodyServiceId === "code-queue")) {
|
||||
reason = "steer-endpoint-404";
|
||||
scope = "code-queue-runtime";
|
||||
retryable = false;
|
||||
} else if (status === 404) {
|
||||
reason = "proxy-404";
|
||||
scope = "stable-proxy";
|
||||
retryable = false;
|
||||
} else if (status === 400 || status === 405 || status === 409 || /active run|steerable|scheduler-only|read-only|prompt is required/u.test(lowerMessage)) {
|
||||
reason = "upstream-runtime-rejected";
|
||||
scope = "code-queue-runtime";
|
||||
retryable = status === 409;
|
||||
} else if (status === 502 || status === 503 || status === 504 || /proxy|tunnel|provider|k3sctl|adapter|timed out|timeout|unavailable|disconnected/u.test(lowerMessage) || bodyStage.length > 0) {
|
||||
reason = "stable-proxy-failed";
|
||||
scope = "stable-proxy";
|
||||
retryable = true;
|
||||
}
|
||||
|
||||
return {
|
||||
reason,
|
||||
scope,
|
||||
status,
|
||||
exitCode,
|
||||
retryable,
|
||||
message,
|
||||
requestPath: targetPath,
|
||||
stableProxyPath,
|
||||
upstreamBodyPreview: previewJson(body ?? record, { maxDepth: 3, maxArrayItems: 3, maxObjectKeys: 16, maxStringLength: 320 }),
|
||||
rawProxyEquivalent,
|
||||
recommendedCrossChecks: [
|
||||
"bun scripts/cli.ts codex queues",
|
||||
"bun scripts/cli.ts codex tasks --view supervisor --limit 20",
|
||||
"bun scripts/cli.ts codex task <taskId>",
|
||||
"bun scripts/cli.ts microservice health code-queue",
|
||||
"bun scripts/cli.ts microservice diagnostics code-queue",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function unwrapSteerResponse(response: unknown, targetPath: string, stableProxyPath: string, rawProxyEquivalent: string): { ok: true; upstream: { ok: unknown; status: unknown }; body: Record<string, unknown> } | { ok: false; diagnostics: ClassifiedCodexSteerError } {
|
||||
const record = asRecord(response);
|
||||
const body = responseBody(record);
|
||||
if (record?.ok === true && body?.ok === true) return { ok: true, upstream: { ok: record.ok, status: record.status }, body };
|
||||
return { ok: false, diagnostics: classifySteerFailure(response, targetPath, stableProxyPath, rawProxyEquivalent) };
|
||||
}
|
||||
|
||||
function positiveIntegerOption(args: string[], names: string[], defaultValue: number, maxValue = Number.MAX_SAFE_INTEGER): number {
|
||||
for (const name of names) {
|
||||
const index = args.indexOf(name);
|
||||
@@ -917,6 +1044,10 @@ export function codexJudgeQuery(taskId: string, optionArgs: string[], fetcher: C
|
||||
return codexTaskJudge(taskId, parseJudgeOptions(optionArgs), fetcher);
|
||||
}
|
||||
|
||||
export function codexSteerTaskForTest(taskId: string, optionArgs: string[], fetcher: CodexResponseFetcher): unknown {
|
||||
return codexSteerTask(taskId, optionArgs, fetcher);
|
||||
}
|
||||
|
||||
function isTerminalTaskStatus(status: unknown): boolean {
|
||||
return status === "succeeded" || status === "failed" || status === "canceled";
|
||||
}
|
||||
@@ -1779,12 +1910,21 @@ function codexInterruptTask(taskId: string): unknown {
|
||||
};
|
||||
}
|
||||
|
||||
function codexSteerTask(taskId: string, args: string[]): unknown {
|
||||
function codexSteerTask(taskId: string, args: string[], fetcher: CodexResponseFetcher = coreInternalFetch): unknown {
|
||||
const options = parseSteerOptions(args);
|
||||
const prompt = textView(options.prompt, true, 3000);
|
||||
const targetPath = `/api/tasks/${encodeURIComponent(taskId)}/steer`;
|
||||
const stableProxyPath = codeQueueProxyPath(targetPath);
|
||||
const rawProxyEquivalent = codeQueueProxyEquivalentCommand(targetPath, "{\"prompt\":\"...\"}");
|
||||
const prompt = textView(options.prompt, false, steerPromptPreviewChars);
|
||||
const request = {
|
||||
path: `/api/tasks/${taskId}/steer`,
|
||||
path: targetPath,
|
||||
stableProxyPath,
|
||||
method: "POST",
|
||||
bodySummary: {
|
||||
promptChars: options.prompt.length,
|
||||
promptPreviewChars: steerPromptPreviewChars,
|
||||
promptTruncated: prompt.truncated,
|
||||
},
|
||||
body: { prompt },
|
||||
};
|
||||
if (options.dryRun) {
|
||||
@@ -1794,11 +1934,28 @@ function codexSteerTask(taskId: string, args: string[]): unknown {
|
||||
request,
|
||||
commands: {
|
||||
run: `bun scripts/cli.ts codex steer ${taskId} --prompt-file <path>`,
|
||||
rawProxy: `bun scripts/cli.ts microservice proxy code-queue /api/tasks/${encodeURIComponent(taskId)}/steer --method POST --body-json '{"prompt":"..."}'`,
|
||||
rawProxy: rawProxyEquivalent,
|
||||
},
|
||||
};
|
||||
}
|
||||
const response = unwrapSteerResponse(fetcher(stableProxyPath, { method: "POST", body: { prompt: options.prompt } }), targetPath, stableProxyPath, rawProxyEquivalent);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
steer: {
|
||||
accepted: false,
|
||||
prompt,
|
||||
},
|
||||
request,
|
||||
diagnostics: response.diagnostics,
|
||||
commands: {
|
||||
dryRun: `bun scripts/cli.ts codex steer ${taskId} --prompt-file <path> --dry-run`,
|
||||
rawProxy: rawProxyEquivalent,
|
||||
tasks: "bun scripts/cli.ts codex tasks --view supervisor --limit 20",
|
||||
health: "bun scripts/cli.ts microservice health code-queue",
|
||||
},
|
||||
};
|
||||
}
|
||||
const response = unwrapCodexResponse(coreInternalFetch(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/steer`), { method: "POST", body: { prompt: options.prompt } }));
|
||||
return {
|
||||
upstream: response.upstream,
|
||||
steer: {
|
||||
|
||||
@@ -32,6 +32,14 @@ export function coreInternalFetch(path: string, init?: { method?: string; body?:
|
||||
const command = dockerCoreFetchCommand(path, init);
|
||||
const result = runCommand(command, repoRoot);
|
||||
if (result.exitCode !== 0) {
|
||||
const parsedStdout = parseJsonRecord(result.stdout.trim());
|
||||
if (parsedStdout !== null) {
|
||||
return {
|
||||
...parsedStdout,
|
||||
commandExitCode: result.exitCode,
|
||||
commandStderrTail: result.stderr.slice(-1200),
|
||||
};
|
||||
}
|
||||
const codeQueueStableProxy = path.startsWith("/api/microservices/code-queue/");
|
||||
return {
|
||||
ok: false,
|
||||
@@ -55,6 +63,16 @@ export function coreInternalFetch(path: string, init?: { method?: string; body?:
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonRecord(text: string): Record<string, unknown> | null {
|
||||
if (text.length === 0) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function requireId(value: string | undefined, command: string): string {
|
||||
if (value === undefined || value.length === 0) throw new Error(`${command} requires microservice id`);
|
||||
return value;
|
||||
|
||||
Reference in New Issue
Block a user