执行通道断开也算失败终态:诊断记在执行适配器上,事件带 transport-failed 载荷
- ExecutionAdapter 新增 transport_failed / transport_failure:调用方只给宿主诊断,文案、报告与"这算不算失败"都归适配器管;先同步记事实,再把同一份原因补进宿主交付报告 - 判据收在适配器里(is_closed):宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)会关掉同一条连接、发同一个 TransportClosed,那些不算失败,调用点两条分支的控制流保持不变;连接自己断掉才算,且只认第一份原因(第一份最接近现场,含 exitStatus 与 stderr 摘要) - lifecycle_status 见到这条事实一律返回 failed:连接不是被本轮主动收束,也没有"用户主动停止"这层授权,报成 interrupted 只会让界面停在"本轮已结束"却不给原因 - 连接级故障(app-server 进程退出 / 流断 / JSON 行越界 / stderr 读取失败)在收束连接之前先把事实记到本回合的执行适配器上,避免与盯着同一个 closed 标志的看门狗抢时序 - direct_turn_failure 增加第三来源且优先级最高:通道断开时原因取宿主诊断,不取只会说"收束到哪一步"的交付报告 - 单测三条:适配器把诊断记成失败终态且只认第一份原因;宿主自己关的连接不算失败;失败载荷优先取宿主诊断(含与 LlmError 并存时的优先级)
This commit is contained in:
@@ -147,6 +147,9 @@ pub(super) struct ExecutionAdapter {
|
||||
changed: Notify,
|
||||
shutdown_gate: tokio::sync::Mutex<()>,
|
||||
outcome: watch::Sender<Option<HostOutcome>>,
|
||||
/// 宿主自己观察到的"执行通道断开":有值就代表本轮不是被主动收束的,终态必须是失败。
|
||||
/// 内容是给用户看的完整原因(策略句 + 宿主诊断),与交付报告同一份文本。
|
||||
transport_failure: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
fn identity(value: Option<&Value>) -> Option<&str> {
|
||||
@@ -263,6 +266,7 @@ impl ExecutionAdapter {
|
||||
changed: Notify::new(),
|
||||
shutdown_gate: tokio::sync::Mutex::new(()),
|
||||
outcome,
|
||||
transport_failure: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -672,6 +676,40 @@ impl ExecutionAdapter {
|
||||
let _ = tokio::task::spawn_blocking(move || session.interrupt(message)).await;
|
||||
}
|
||||
|
||||
/// 执行通道断开(app-server 进程退出 / 流断 / 回合事件通道关闭)时的收口入口:调用方只给
|
||||
/// 宿主诊断,文案、"这算不算失败"、报告都归这里管。
|
||||
///
|
||||
/// **宿主自己关的连接不算失败。** 正常终态、用户主动停止、预算与交付收尾都会把连接关掉,回合
|
||||
/// 事件通道上看到的是同一个 `TransportClosed`;区分判据是 [`Self::is_closed`]——适配器先于连接
|
||||
/// 置位就说明这一轮是宿主在收束,只按既有口径中断收口(原因照样写进报告,便于核对)。
|
||||
///
|
||||
/// **连接自己断的才算失败,且事实要落在适配器上。** 调用点局部变量不行:回合还开着的时候,
|
||||
/// 看门狗会在同一个 `inner.closed` 标志上把本轮收束掉(见 [`Self::start_watchdog`]),谁先谁后
|
||||
/// 取决于调度,而终态判定发生在收束之后。记不下原因,界面就只能看到"本轮已结束"、看不到为什么。
|
||||
/// 所以顺序是:先**同步**记事实(终态随之判失败),再写报告。
|
||||
///
|
||||
/// 只记第一份原因:第一份最接近现场(连接终止时带 exitStatus / stderr 摘要),后面更粗的收束
|
||||
/// 理由(事件通道关闭、看门狗收尾)不得覆盖它。
|
||||
pub(super) async fn transport_failed(&self, diagnostic: &str) {
|
||||
let reason = format!("执行通道已断开,不能自动重放未确认操作:{diagnostic}");
|
||||
if !self.is_closed() {
|
||||
if let Ok(mut slot) = self.transport_failure.lock() {
|
||||
if slot.is_none() {
|
||||
*slot = Some(reason.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
self.interrupt(&reason).await;
|
||||
}
|
||||
|
||||
/// 本轮是否以"执行通道断开"收场;有值就是宿主记下的那份原因。终态判定只读这一次。
|
||||
pub(super) fn transport_failure(&self) -> Option<String> {
|
||||
self.transport_failure
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|slot| slot.clone())
|
||||
}
|
||||
|
||||
pub(super) fn start_watchdog(self: &Arc<Self>, inner: Weak<CodexAppServerInner>) {
|
||||
let adapter = Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
@@ -744,7 +782,21 @@ impl ExecutionAdapter {
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// 本轮是不是**由宿主自己**在收束(正常终态 / 用户主动停止 / 预算收尾 / 交付封口)。
|
||||
///
|
||||
/// 用来把"连接被我们关掉"和"连接自己断了"分开:两种情况下回合事件通道都会收到
|
||||
/// `TransportClosed`,但只有后者才算执行通道失败(见 [`Self::transport_failed`])。
|
||||
/// `finish_model_attempt` 与 `shutdown_and_report` 都会在收束连接之前把它置位。
|
||||
fn is_closed(&self) -> bool {
|
||||
self.closed.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(super) fn lifecycle_status(&self, fallback: &str) -> String {
|
||||
// 执行通道断开过的回合一律是失败:那不是本轮主动收束,也没有"用户主动停止"这层授权,
|
||||
// 报成 `interrupted` 只会让界面停在"本轮已结束"却不给原因(这就是连接被强杀时的老现象)。
|
||||
if self.transport_failure().is_some() {
|
||||
return "failed".to_string();
|
||||
}
|
||||
match self.session.snapshot().map(|state| state.phase) {
|
||||
Ok(ExecutionPhase::Completed) => "completed",
|
||||
Ok(ExecutionPhase::Exhausted | ExecutionPhase::Interrupted) => "interrupted",
|
||||
@@ -1084,6 +1136,50 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transport_failure_is_a_failed_terminal_with_the_host_diagnostic() {
|
||||
let (_temp, adapter) = fixture();
|
||||
assert_eq!(adapter.lifecycle_status("completed"), "completed");
|
||||
|
||||
adapter
|
||||
.transport_failed("Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)")
|
||||
.await;
|
||||
|
||||
// 终态判成失败:界面才有理由把它当失败讲,而不是"本轮已结束"。
|
||||
assert_eq!(adapter.lifecycle_status("completed"), "failed");
|
||||
let reason = adapter
|
||||
.transport_failure()
|
||||
.expect("host diagnostic must be recorded");
|
||||
assert!(reason.contains("SIGKILL"));
|
||||
// 报告与事件载荷同一份原因:用户看到的现象和交付状态要对得上。
|
||||
assert!(adapter.report().contains("SIGKILL"));
|
||||
|
||||
// 只认第一份原因:后续更粗的收束理由不得覆盖真实诊断。
|
||||
adapter
|
||||
.transport_failed("Codex app-server turn 事件通道已关闭")
|
||||
.await;
|
||||
let reason = adapter.transport_failure().expect("first reason is kept");
|
||||
assert!(reason.contains("SIGKILL"));
|
||||
assert!(!reason.contains("事件通道已关闭"));
|
||||
}
|
||||
|
||||
/// 宿主自己关的连接不算传输失败:正常终态、用户主动停止、预算与交付收尾都会关掉连接,回合事件
|
||||
/// 通道上看到的是同一个 `TransportClosed`。判据是适配器先于连接置位 `closed`。
|
||||
#[tokio::test]
|
||||
async fn host_ended_turn_is_not_a_transport_failure() {
|
||||
let (_temp, adapter) = fixture();
|
||||
adapter.closed.store(true, Ordering::Release);
|
||||
|
||||
adapter
|
||||
.transport_failed("模型本次执行结束,回收原生后台子树")
|
||||
.await;
|
||||
|
||||
assert!(adapter.transport_failure().is_none());
|
||||
assert_ne!(adapter.lifecycle_status("completed"), "failed");
|
||||
// 原因照样进报告:不算失败不等于不用记。
|
||||
assert!(adapter.report().contains("不能自动重放未确认操作"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_snapshot_identity_uses_canonical_digest_and_preserves_manifest_authority() {
|
||||
let (_temp, adapter) = fixture();
|
||||
|
||||
@@ -3995,9 +3995,10 @@ impl CodexAppServerConnection {
|
||||
Some(CodexTurnEvent::TransportClosed(error)) => {
|
||||
if let Some(adapter) = approval_adapter.as_ref() {
|
||||
if !adapter.is_host_ending() {
|
||||
adapter
|
||||
.interrupt("执行通道已断开,不能自动重放未确认操作。")
|
||||
.await;
|
||||
// 事件带的 `error` 就是连接终止时那份诊断。通道断开是不是'失败'由
|
||||
// 适配器判(宿主自己关的连接不算),失败事实也记在它上面,回合终态
|
||||
// 判定之后才读得到:见 `ExecutionAdapter::transport_failed`。
|
||||
adapter.transport_failed(&error).await;
|
||||
}
|
||||
return execution::outcome_text(adapter.wait_outcome().await);
|
||||
}
|
||||
@@ -4010,8 +4011,10 @@ impl CodexAppServerConnection {
|
||||
None => {
|
||||
if let Some(adapter) = approval_adapter.as_ref() {
|
||||
if !adapter.is_host_ending() {
|
||||
// 事件通道在没有终态的情况下关掉,和连接断掉是同一件事:本轮只可能
|
||||
// 以失败收口,不能报成"被中断"。
|
||||
adapter
|
||||
.interrupt("执行事件通道已结束,正在核对后台操作。")
|
||||
.transport_failed("Codex app-server turn 事件通道已关闭")
|
||||
.await;
|
||||
}
|
||||
return execution::outcome_text(adapter.wait_outcome().await);
|
||||
@@ -4073,9 +4076,15 @@ impl CodexAppServerConnection {
|
||||
// 脱敏 + 截断后写进去),其余(`completed` / `interrupted` / `aborted`)不带载荷。
|
||||
// 失败不再只写一个 `status="failed"`:那让失败与正常结束在协议上长得一样,前端只能
|
||||
// 另开一条通道(命令返回 / 另一条 IPC)去拿原因,也就等于承认事件流讲不清一轮怎么结束。
|
||||
// 通道断开的失败原因取自执行适配器(宿主亲眼看到的断连事实),不从交付报告里猜:
|
||||
// 报告只说明收束状态,说不清连接为什么没了。
|
||||
let transport_failure = approval_adapter
|
||||
.as_ref()
|
||||
.and_then(|adapter| adapter.transport_failure());
|
||||
let failure = direct_turn_failure(
|
||||
&status,
|
||||
collect_result.as_ref().map(String::as_str),
|
||||
transport_failure.as_deref(),
|
||||
history_root,
|
||||
);
|
||||
match failure {
|
||||
@@ -4991,6 +5000,10 @@ async fn fail_game_creator_codex_app_server_connection(
|
||||
let stderr = inner.stderr_summary.lock().await.diagnostic();
|
||||
let diagnostic = format!("{error};exitStatus={exit_status};{stderr}");
|
||||
app_log!("agent.runner.failed: Codex app-server 连接终止:{diagnostic}");
|
||||
// 连接是在回合进行中断掉的:先把"本轮以传输失败收口"和这份诊断记到执行适配器上,再去收束
|
||||
// 连接。顺序不能反——执行适配器的看门狗盯着同一个 `closed` 标志,它可能先一步把本轮收束成
|
||||
// "被中断";终态一旦算出来,失败原因就只剩日志,界面只会看到"本轮已结束、没有原因"。
|
||||
record_execution_transport_failure(&inner, &diagnostic).await;
|
||||
match shutdown_game_creator_codex_app_server_inner(&inner, &diagnostic).await {
|
||||
Ok(proof) if proof.confirmed() => {}
|
||||
Ok(_) => app_log!("Codex app-server 连接终止:process-group-only,完整子树退出未确认"),
|
||||
@@ -4998,6 +5011,23 @@ async fn fail_game_creator_codex_app_server_connection(
|
||||
}
|
||||
}
|
||||
|
||||
/// 把"执行通道断开"这个失败事实记到当前回合的执行适配器上:连接级故障与回合事件通道关闭共用
|
||||
/// 这一条路径,别在两处各写一份。没有进行中的 DirectProject 回合(适配器已释放)就是空操作。
|
||||
async fn record_execution_transport_failure(inner: &Arc<CodexAppServerInner>, diagnostic: &str) {
|
||||
let adapter = {
|
||||
let slot = match inner.execution.lock() {
|
||||
Ok(slot) => slot,
|
||||
Err(_) => return,
|
||||
};
|
||||
// 只借一下指针:后面要 await(写交付报告),不能带着执行槽位的锁等。
|
||||
slot.as_ref().map(Arc::clone)
|
||||
};
|
||||
let Some(adapter) = adapter else {
|
||||
return;
|
||||
};
|
||||
adapter.transport_failed(diagnostic).await;
|
||||
}
|
||||
|
||||
async fn shutdown_game_creator_codex_app_server_inner(
|
||||
inner: &Arc<CodexAppServerInner>,
|
||||
reason: &str,
|
||||
|
||||
@@ -27,6 +27,10 @@ const DIRECT_TURN_FAILURE_HOST_DROPPED_KIND: &str = "host-dropped";
|
||||
const DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE: &str =
|
||||
"陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。";
|
||||
|
||||
/// 执行通道断开的分类:宿主自己看到的事实(app-server 进程退出 / 流断 / 回合事件通道关闭),
|
||||
/// 不由 `LlmError` 反推——那种情况下宿主手里只有一份交付报告,报告里没有"连接没了"这句真话。
|
||||
const DIRECT_TURN_FAILURE_TRANSPORT_KIND: &str = "transport-failed";
|
||||
|
||||
/// 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected`。
|
||||
///
|
||||
/// 分类只影响界面语气,前端不得拿它做流程分支(流程判据只有"收到终态事件"这一条)。
|
||||
@@ -35,7 +39,7 @@ fn direct_turn_failure_kind(error: &LlmError) -> &'static str {
|
||||
LlmError::Timeout { .. } => "timeout",
|
||||
LlmError::InvalidConfig(_) | LlmError::InvalidRequest(_) => "request-rejected",
|
||||
LlmError::Connectivity { .. } | LlmError::Transport(_) | LlmError::StreamUnavailable => {
|
||||
"transport-failed"
|
||||
DIRECT_TURN_FAILURE_TRANSPORT_KIND
|
||||
}
|
||||
LlmError::Upstream { .. } | LlmError::EmptyResponse | LlmError::Deserialize(_) => {
|
||||
"model-failed"
|
||||
@@ -45,24 +49,37 @@ fn direct_turn_failure_kind(error: &LlmError) -> &'static str {
|
||||
|
||||
/// 这一轮的终态是不是「失败」?是的话给出失败载荷(原因已脱敏并截断)。
|
||||
///
|
||||
/// 失败有两个来源,都必须进 `turn.completed(status="failed")` 的 `failure` 载荷:
|
||||
/// - `collect_result` 是错误:真失败(模型 / 传输 / 历史落盘),原因直接从错误里取;
|
||||
/// - `collect_result` 是交付报告、但 `status` 已经判成 `failed`:宿主收束了一个失败的回合,
|
||||
/// 原因用那份报告本身(它本来就是给用户看的失败说明)。
|
||||
/// 失败有三个来源,都必须进 `turn.completed(status="failed")` 的 `failure` 载荷,按优先级:
|
||||
/// 1. `transport_failure` 有值:宿主亲眼看到执行通道断开(app-server 进程退出、流断、回合事件
|
||||
/// 通道关闭)。原因就是宿主记下的那份诊断(含 exitStatus / stderr 摘要),它比交付报告更接近
|
||||
/// 现场;报告只说明"收束到哪一步",说不清连接为什么没了;
|
||||
/// 2. `collect_result` 是错误:真失败(模型 / 传输 / 历史落盘),原因直接从错误里取;
|
||||
/// 3. `collect_result` 是交付报告、但 `status` 已经判成 `failed`:宿主收束了一个失败的回合,
|
||||
/// 原因用那份报告本身(它本来就是给用户看的失败说明)。
|
||||
///
|
||||
/// 其余终态(`completed` / `interrupted` / `aborted`)都不是失败,返回 `None`,事件不带载荷。
|
||||
/// 注意这里只负责"原因写什么":把 `status` 判成 `failed` 是调用方的事,通道断开必须让宿主把本轮
|
||||
/// 判失败(`ExecutionAdapter::lifecycle_status` 就是这么做的)——只补一条载荷而状态还是
|
||||
/// `interrupted`,界面照样不会把它当成失败来讲。
|
||||
pub(crate) fn direct_turn_failure(
|
||||
status: &str,
|
||||
collect_result: Result<&str, &LlmError>,
|
||||
transport_failure: Option<&str>,
|
||||
history_root: &Path,
|
||||
) -> Option<DirectTurnFailure> {
|
||||
let (kind, message) = match collect_result {
|
||||
Err(error) => (
|
||||
let (kind, message) = match (transport_failure, collect_result) {
|
||||
(Some(diagnostic), _) => (
|
||||
DIRECT_TURN_FAILURE_TRANSPORT_KIND.to_string(),
|
||||
diagnostic.to_string(),
|
||||
),
|
||||
(None, Err(error)) => (
|
||||
direct_turn_failure_kind(error).to_string(),
|
||||
error.to_string(),
|
||||
),
|
||||
Ok(report) if status == "failed" => ("model-failed".to_string(), report.to_string()),
|
||||
Ok(_) => return None,
|
||||
(None, Ok(report)) if status == "failed" => {
|
||||
("model-failed".to_string(), report.to_string())
|
||||
}
|
||||
(None, Ok(_)) => return None,
|
||||
};
|
||||
Some(DirectTurnFailure::new(
|
||||
kind,
|
||||
@@ -186,22 +203,22 @@ mod tests {
|
||||
fn only_failed_terminals_carry_a_failure_payload() {
|
||||
// 正常终态:无论交付报告写了什么都不是失败。
|
||||
assert_eq!(
|
||||
direct_turn_failure("completed", Ok("本轮交付已完成"), &history_root()),
|
||||
direct_turn_failure("completed", Ok("本轮交付已完成"), None, &history_root()),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
direct_turn_failure("interrupted", Ok("本轮已被终止"), &history_root()),
|
||||
direct_turn_failure("interrupted", Ok("本轮已被终止"), None, &history_root()),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
direct_turn_failure("aborted", Ok("已结束这一轮占用"), &history_root()),
|
||||
direct_turn_failure("aborted", Ok("已结束这一轮占用"), None, &history_root()),
|
||||
None
|
||||
);
|
||||
|
||||
// 失败且拿得到错误:分类取自错误,原因取自错误文本。
|
||||
let error =
|
||||
LlmError::Transport("DirectProject 收尾历史失败:写入 project.jsonl 失败".into());
|
||||
let failure = direct_turn_failure("failed", Err(&error), &history_root())
|
||||
let failure = direct_turn_failure("failed", Err(&error), None, &history_root())
|
||||
.expect("transport error must produce a failure payload");
|
||||
assert_eq!(failure.kind, "transport-failed");
|
||||
assert!(failure.message.contains("收尾历史失败"));
|
||||
@@ -210,6 +227,7 @@ mod tests {
|
||||
let failure = direct_turn_failure(
|
||||
"failed",
|
||||
Ok("宿主尚未确认交付完成;请核对未完成项。"),
|
||||
None,
|
||||
&history_root(),
|
||||
)
|
||||
.expect("failed status must produce a failure payload");
|
||||
@@ -217,21 +235,61 @@ mod tests {
|
||||
assert_eq!(failure.message, "宿主尚未确认交付完成;请核对未完成项。");
|
||||
}
|
||||
|
||||
/// 执行通道断开:连接被强杀 / 流断时宿主手里只有交付报告,但真相是连接没了。原因必须用宿主
|
||||
/// 记下的诊断,而不是那份只说"收束到哪一步"的报告——否则界面只能看到一句泛泛的收尾说明。
|
||||
#[test]
|
||||
fn host_observed_transport_failure_outranks_the_delivery_report() {
|
||||
let diagnostic = "执行通道已断开,不能自动重放未确认操作:Codex app-server 已退出;\
|
||||
exitStatus=signal: 9 (SIGKILL);stderrClass=nonempty;stderrBytes=1000";
|
||||
let failure = direct_turn_failure(
|
||||
"failed",
|
||||
Ok("执行连接已结束,正在核对自有子进程与在途操作。"),
|
||||
Some(diagnostic),
|
||||
&history_root(),
|
||||
)
|
||||
.expect("transport failure must produce a failure payload");
|
||||
assert_eq!(failure.kind, DIRECT_TURN_FAILURE_TRANSPORT_KIND);
|
||||
assert!(failure.message.contains("SIGKILL"));
|
||||
assert!(!failure.message.contains("正在核对自有子进程"));
|
||||
|
||||
// 即使同时拿到了错误,通道断开仍是本轮的第一事实。
|
||||
let error = LlmError::Transport("DirectProject 收尾历史失败".into());
|
||||
let failure = direct_turn_failure(
|
||||
"failed",
|
||||
Err(&error),
|
||||
Some("执行通道已断开,不能自动重放未确认操作:Codex app-server 已退出"),
|
||||
&history_root(),
|
||||
)
|
||||
.expect("transport failure must produce a failure payload");
|
||||
assert_eq!(failure.kind, DIRECT_TURN_FAILURE_TRANSPORT_KIND);
|
||||
assert!(failure.message.contains("Codex app-server 已退出"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_message_is_redacted_and_truncated() {
|
||||
let root = history_root();
|
||||
let with_path = format!("落盘失败:{} 不可写", root.display());
|
||||
let failure = direct_turn_failure("failed", Ok(&with_path), &history_root())
|
||||
let failure = direct_turn_failure("failed", Ok(&with_path), None, &history_root())
|
||||
.expect("failed status must produce a failure payload");
|
||||
assert!(!failure.message.contains("/tmp/direct-turn-failure-test"));
|
||||
assert!(failure.message.contains("$PROJECT_ROOT"));
|
||||
|
||||
let long = "x".repeat(4_000);
|
||||
let failure = direct_turn_failure("failed", Ok(&long), &history_root())
|
||||
let failure = direct_turn_failure("failed", Ok(&long), None, &history_root())
|
||||
.expect("failed status must produce a failure payload");
|
||||
// 按字符截断,最多再多一个省略号标记。
|
||||
assert!(failure.message.chars().count() <= DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS + 1);
|
||||
assert!(failure.message.ends_with('…'));
|
||||
|
||||
// 通道断开的诊断同样要脱敏 + 截断:它比报告长得多,且可能带本机路径。
|
||||
let diagnostic = format!(
|
||||
"执行通道已断开:Codex app-server 已退出;路径 {}",
|
||||
root.display()
|
||||
);
|
||||
let failure = direct_turn_failure("failed", Ok("报告"), Some(&diagnostic), &history_root())
|
||||
.expect("transport failure must produce a failure payload");
|
||||
assert!(!failure.message.contains("/tmp/direct-turn-failure-test"));
|
||||
assert!(failure.message.contains("$PROJECT_ROOT"));
|
||||
}
|
||||
|
||||
/// 兜底:守卫武装后没被解除就 Drop,必须补一条失败终态(panic / future 被丢弃走的就是这条)。
|
||||
|
||||
Reference in New Issue
Block a user