diff --git a/.github/workflows/sdk-java.yml b/.github/workflows/sdk-java.yml
index fb03499744e..f90ab6d45f2 100644
--- a/.github/workflows/sdk-java.yml
+++ b/.github/workflows/sdk-java.yml
@@ -1097,7 +1097,7 @@ jobs:
name: 'Hosted process fault gates / MySQL 8.4 / Java 21'
if: "${{ inputs.tier != 'fast' }}"
runs-on: 'ubuntu-latest'
- # The step ceilings today (203 + 20 + 50 + 20 = 293 — the FG8 letters
+ # The step ceilings today (204 + 20 + 50 + 20 = 294 — the FG8 letters
# grew the shared failsafe fork to 3600 s, FG9 slice 3 to 3990 s, the
# FG10b–f workflow relay letters to 4484 s, the FG12a MCP reply-loss
# letter to 4499 s, the FG12c restart matrix to 4548 s, the FG13c Hook
@@ -1174,18 +1174,21 @@ jobs:
# margin (12104 s of fork plus wrap, 16 s left); the FG12d two-server
# release cell adds 32 s (16.2 s measured), so the fork moves to 11536 s
# and Verify to 203 minutes, wrapping fork + 600 s plus margin (12136 s
- # of fork plus wrap, 44 s left);
+ # of fork plus wrap, 44 s left); the FG12d detach-unknown cell adds 55 s
+ # (27.4 s measured), so the fork moves to 11591 s and Verify to 204
+ # minutes, wrapping fork + 600 s plus margin (12191 s of fork plus wrap,
+ # 49 s left);
# the Broker fault gates use 20,
# and admission plus O4 keep per-fork ceilings) plus a 33-minute
# allowance for the uncapped setup steps (Checkout.. cgroup delegation,
# the report checks), which took under 10 minutes on every nightly from
- # 10-14 to 10-30: 293 + 33. A Verify that timed out used to skip
+ # 10-14 to 10-30: 294 + 33. A Verify that timed out used to skip
# admission, leaving that path 50 minutes spare; admission now runs
# after it, so that path too can fill all four capped ceilings. The
# allowance sits above the ~13-minute floor so that a slow setup (the
# CLI install makes up to three attempts) still leaves the O4 gates
# time to run. The failover arms run in hosted-failover-e2e.
- timeout-minutes: 326
+ timeout-minutes: 327
services:
mysql:
image: 'mysql:8.4.6'
@@ -1285,10 +1288,10 @@ jobs:
# fork leaves no per-test failure lines, so the main-CI failure
# analyzer can only file an undiagnosable per-commit issue (#13503).
# hosted-process-ci.test.js pins this ordering; the fork budget is
- # 11536 s, so the step wraps to fork + 600 s plus margin (203 min,
- # 44 s left before the step must move), including the provisional
+ # 11591 s, so the step wraps to fork + 600 s plus margin (204 min,
+ # 49 s left before the step must move), including the provisional
# 180 s pool-fixture reservation pending CI measurement.
- timeout-minutes: 203
+ timeout-minutes: 204
env:
MAVEN_ARGS: '--settings ${{ runner.temp }}/setup-java-m2/settings.xml --toolchains ${{ runner.temp }}/setup-java-m2/toolchains.xml'
MYSQL_PORT: "${{ job.services.mysql.ports['3306'] }}"
diff --git a/docs/design/2026-10-18-mcp-release-fault-gates-d.md b/docs/design/2026-10-18-mcp-release-fault-gates-d.md
index a5119158e79..54757e3b2ac 100644
--- a/docs/design/2026-10-18-mcp-release-fault-gates-d.md
+++ b/docs/design/2026-10-18-mcp-release-fault-gates-d.md
@@ -132,3 +132,21 @@ Decisions:
Mutation witness: `drainConfigurations` iterates `configurations.slice(0, 1)` in a copy of the packaged CLI. The two-server case is red at `settle must complete` because the second configuration stays active; the one-server case stays green, so this case is the one that pins the second configuration. The restored bundle is green.
Budget: the two-server case measures 16.16–16.24 s standalone (two runs) on JDK 21, MySQL 8.4.6 and Linux x86_64; the ten-case class is 81.6 s. It adds 32 s (2×) to the shared healthy sum (11504 → 11536 s), so the `hosted-harness-mysql` fork moves to 11536 s and the Verify step to 203 minutes (12136 s of fork plus wrap); the job ceiling moves to 326 (293 + 33). The `hosted-mcp-gates` profile and the artifact root are unchanged.
+
+## Follow-up cell: detach refused while a tool result is unknown
+
+#15750's FG12d row also asks for a detach that refuses while a tool result is unknown. The class's existing unknown-result cases (`stdioKillDuringRequestSettlesOutcomeUnknownOnMySql` and `workerKillDuringOperationSettlesOutcomeUnknownOnMySql`) stop at the unknown; this follow-up attempts the Harness detach route while the record is `recovery_blocked` / `outcome_unknown`.
+
+| Case | Injection | Expect |
+| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `detach-refused-unknown-result` | Start an `ext://hold` raw operation through the private route; kill the stdio fixture; wait for `outcome_unknown` / `recovery_blocked`; POST the Harness detach route | Detach answers 409 `hosted_turn_active` or 503 `managed_session_close_failed` (the close catches the MCP recovery error); no `mcp-release` control; the operation stays unknown; the Session remains attached and reopenable. |
+
+Decisions:
+
+1. **The Harness detach route is the gate.** The control-plane lifecycle close is exercised by the R1-1 and two-server cells; this cell calls `POST /session/{id}/detach` directly, which is the production detach that reaches `HostedMcpSession.close()`.
+2. **The unknown is permanent, not a timed window.** The stdio fixture is killed with an `ext://hold` read in flight, so the operation can only settle through a conclusive answer from its original Runtime generation. The detach has no window to race.
+3. **The refusal is one of the documented close answers.** The test accepts 409 `hosted_turn_active` (MCP busy/recovering) and 503 `managed_session_close_failed` / `hosted_mcp_recovery_required`; it rejects 200/204 and asserts no release control and an unchanged unknown record.
+
+Mutation witness: wrap `session.mcp?.close()` in the close path of a packaged CLI copy in `try { … } catch {}`; detach answers 204 and the case is red at the status assertion. The restored bundle is green.
+
+Budget: the case measures 27.4 s worst of two runs (27.4 s and 17.4 s) on JDK 21, MySQL 8.4.6 and Linux x86_64; booked at 55 s (2×). The `hosted-harness-mysql` fork moves to 11591 s, the Verify step to 204 minutes and the job ceiling to 327 (294 + 33).
diff --git a/docs/design/2026-10-18-mcp-release-fault-gates-d.zh-CN.md b/docs/design/2026-10-18-mcp-release-fault-gates-d.zh-CN.md
index a5d59f79d72..202cf323816 100644
--- a/docs/design/2026-10-18-mcp-release-fault-gates-d.zh-CN.md
+++ b/docs/design/2026-10-18-mcp-release-fault-gates-d.zh-CN.md
@@ -132,3 +132,21 @@ v2 关闭被准入之后,store 会 fence 普通写者续租,而 Harness 要
变异见证:在打包 CLI 的副本中让 `drainConfigurations` 只遍历 `configurations.slice(0, 1)`。两个 server 的用例在 `settle must complete` 处变红,因为第二个配置保持 active;一个 server 的用例仍绿,因此是这个用例钉住了第二个配置。还原后的 bundle 复绿。
预算:两个 server 的用例在 JDK 21、MySQL 8.4.6、Linux x86_64 上单独实测 16.16–16.24 秒(两次),十用例整类 81.6 秒。按 2× 记 32 秒加入共享健康总和(11504 → 11536 秒),`hosted-harness-mysql` fork 移到 11536 秒,Verify 步骤移到 203 分钟(含 wrap 共 12136 秒),job 上限移到 326(293 + 33)。`hosted-mcp-gates` profile 与产物根不变。
+
+## 后续格:工具结果未知时 detach 被拒
+
+#15750 的 FG12d 行还要求:工具结果未知时 detach 必须被拒。本类已有的 unknown 结果用例(`stdioKillDuringRequestSettlesOutcomeUnknownOnMySql` 与 `workerKillDuringOperationSettlesOutcomeUnknownOnMySql`)只停在 unknown;本后续格在记录处于 `recovery_blocked` / `outcome_unknown` 时调用 Harness 的 detach 路由。
+
+| 用例 | 注入 | 期望 |
+| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `detach-refused-unknown-result` | 通过私有路由发起 `ext://hold` 原始操作;杀掉 stdio fixture;等 `outcome_unknown` / `recovery_blocked`;POST Harness detach 路由 | detach 返回 409 `hosted_turn_active` 或 503 `managed_session_close_failed`(close 捕获 MCP recovery 错误);没有 `mcp-release` 控制;操作保持 unknown;Session 保持 attached、可重开 |
+
+决策:
+
+1. **以 Harness detach 路由为门禁。** 控制面 lifecycle close 已由 R1-1 与两 server 用例覆盖;本格直接调 `POST /session/{id}/detach`,即生产 detach 到达 `HostedMcpSession.close()` 的路径。
+2. **unknown 是永久的,不是定时窗口。** 带 `ext://hold` 读在飞时杀掉 stdio fixture,操作只能由原 Runtime 代的确定性应答结算;detach 没有可赛跑的窗口。
+3. **拒绝码取自已记录的关闭应答。** 测试接受 409 `hosted_turn_active`(MCP busy/recovering)与 503 `managed_session_close_failed` / `hosted_mcp_recovery_required`;拒绝 200/204,并断言没有 release 控制、记录仍是 unknown。
+
+变异见证:在打包 CLI 副本的 close 路径中把 `session.mcp?.close()` 包进 `try { … } catch {}`;detach 返回 204,用例在状态断言处变红。还原后的 bundle 复绿。
+
+预算:该用例在 JDK 21、MySQL 8.4.6、Linux x86_64 上两次实测取最慢 27.4 秒(27.4/17.4),按 2× 记 55 秒。`hosted-harness-mysql` fork 移到 11591 秒,Verify 移到 204 分钟,job 上限移到 327(294 + 33)。
diff --git a/packages/managed-agent-server/verification/pom.xml b/packages/managed-agent-server/verification/pom.xml
index 7451cf6944a..36910b92333 100644
--- a/packages/managed-agent-server/verification/pom.xml
+++ b/packages/managed-agent-server/verification/pom.xml
@@ -284,7 +284,7 @@
qwen.hosted.mysql.isolation = true
- 11536
+ 11591
diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/HostedMcpRestartFaultGatesIT.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/HostedMcpRestartFaultGatesIT.java
index 6fbcb0da067..75e9636e470 100644
--- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/HostedMcpRestartFaultGatesIT.java
+++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/HostedMcpRestartFaultGatesIT.java
@@ -74,23 +74,28 @@ class HostedMcpRestartFaultGatesIT {
// Per-case evidence letter: FG12c keeps its directory, FG12d's release
// cell lands under its own so the two letters' artefacts never mix.
private String caseLetter;
- private static final Map CASE_LETTERS = Map.of(
- "harnessKillDuringConfigureResolvesThroughStatusOnMySql",
- "fg12c",
- "harnessKillDuringOperationLooksUpTheOriginalInvokeOnMySql",
- "fg12c",
- "workerKillWhileIdleNeverMintsAFreshOwnerOnMySql", "fg12c",
- "workerKillDuringOperationSettlesOutcomeUnknownOnMySql", "fg12c",
- "stdioKillDuringRequestSettlesOutcomeUnknownOnMySql", "fg12c",
- "harnessKillDuringReleaseResumesTheOriginalReleaseOnMySql",
- "fg12d",
- "firstReleaseRefusedBeforeForwardingResumesUnderTheSameId",
- "fg12d",
- "firstReleaseRefusedWithTwoServersReleasesBothOnMySql",
- "fg12d",
- "cancelBeforeDispatchIsNotStartedProvenOnMySql", "fg12d",
- "ownerReleaseRefusedThenSpringRestartReleasesTheHolderOnMySql",
- "fg12d");
+ private static final Map CASE_LETTERS = Map.ofEntries(
+ Map.entry("harnessKillDuringConfigureResolvesThroughStatusOnMySql",
+ "fg12c"),
+ Map.entry("harnessKillDuringOperationLooksUpTheOriginalInvokeOnMySql",
+ "fg12c"),
+ Map.entry("workerKillWhileIdleNeverMintsAFreshOwnerOnMySql",
+ "fg12c"),
+ Map.entry("workerKillDuringOperationSettlesOutcomeUnknownOnMySql",
+ "fg12c"),
+ Map.entry("stdioKillDuringRequestSettlesOutcomeUnknownOnMySql",
+ "fg12c"),
+ Map.entry("detachRefusedWhileToolResultUnknownOnMySql", "fg12d"),
+ Map.entry("harnessKillDuringReleaseResumesTheOriginalReleaseOnMySql",
+ "fg12d"),
+ Map.entry("firstReleaseRefusedBeforeForwardingResumesUnderTheSameId",
+ "fg12d"),
+ Map.entry("firstReleaseRefusedWithTwoServersReleasesBothOnMySql",
+ "fg12d"),
+ Map.entry("cancelBeforeDispatchIsNotStartedProvenOnMySql",
+ "fg12d"),
+ Map.entry("ownerReleaseRefusedThenSpringRestartReleasesTheHolderOnMySql",
+ "fg12d"));
private static final String TOKEN = "extension-process-fixture-token";
private static final String CAPABILITY = "sha256:" + "a".repeat(64);
private final ObjectMapper json = new ObjectMapper();
@@ -665,6 +670,45 @@ class HostedMcpRestartFaultGatesIT {
pending.cancel(true);
}
+ // FG12d (issue #14447): a tool result is unknown, so detach must refuse
+ // before it releases anything. The Session stays attached and the
+ // operation stays recovery_blocked; a later generation owns the only
+ // conclusive answer for its original id.
+ @Test
+ @Timeout(600)
+ void detachRefusedWhileToolResultUnknownOnMySql() throws Exception {
+ SessionRef session = createSession();
+ baseline(session);
+ String operation = uuid();
+ Future pending = caseExecutor.submit(
+ () -> privateRequest("POST", "/session/" + session.id()
+ + "/mcp/operations", Map.of("operationId", operation,
+ "serverId", "fixture", "request", Map.of("kind",
+ "resource_read", "uri", "ext://hold")),
+ session, 202));
+ waitFor(() -> assertThat(fixtureEvents("mcp.hold")).hasSize(1));
+ kill(fixturePid());
+ waitFor(() -> assertThat(operations(session.id()).getLast()
+ .path("run").path("execution").asText())
+ .isEqualTo("outcome_unknown"));
+ assertThat(operations(session.id()).getLast().path("run").path("state")
+ .asText()).isEqualTo("recovery_blocked");
+ HttpResponse detach = rawRequest("POST",
+ "/session/" + session.id() + "/detach", "{}",
+ session.clientId());
+ assertThat(detach.statusCode()).as("detach: %s", detach.body())
+ .isIn(409, 503);
+ JsonNode refusal = json.readTree(detach.body());
+ assertThat(refusal.path("error").asText(
+ refusal.path("code").asText()))
+ .isIn("hosted_turn_active", "managed_session_close_failed",
+ "hosted_mcp_recovery_required");
+ assertThat(controls("mcp-release")).isEmpty();
+ assertThat(operations(session.id()).getLast().path("run").path("execution")
+ .asText()).isEqualTo("outcome_unknown");
+ pending.cancel(true);
+ }
+
private long fixturePid() {
return fixtureEvents("mcp.start").getFirst().path("pid").asLong();
}
diff --git a/scripts/tests/hosted-process-ci.test.js b/scripts/tests/hosted-process-ci.test.js
index 49fe38a9267..a6d63522a51 100644
--- a/scripts/tests/hosted-process-ci.test.js
+++ b/scripts/tests/hosted-process-ci.test.js
@@ -661,13 +661,13 @@ describe('Hosted real-process gates', () => {
});
it.each([
- // Step ceilings today (203 + 20 + 50 + 20): Verify, the Broker fault
+ // Step ceilings today (204 + 20 + 50 + 20): Verify, the Broker fault
// gates, admission and O4. The failover arms moved to their own job.
// The MySQL job's ceiling carries 33 minutes of setup allowance, above
// the 13-minute floor: a Verify that timed out used to skip admission,
// and now that path too can fill all four ceilings, so a slow setup
// must still leave the O4 gates time to run.
- ['hosted-harness-mysql', 293, 326, 33],
+ ['hosted-harness-mysql', 294, 327, 33],
// Twenty-seven failover arms at 10 each; the allowance is ~13 minutes of
// setup plus 2.
['hosted-failover-e2e', 270, 285, 15],
@@ -930,11 +930,13 @@ describe('Hosted real-process gates', () => {
// (21–23 s measured), moving the fork to 11504 and the step to 202
// minutes; the FG12d two-server release cell adds 32 s (2x its
// measured 16.2 s), moving the fork to 11536 and the step to 203
- // minutes until CI measures it: the relational check above tolerates
- // anything up to step*60, so the value is pinned literally here —
- // an unnoticed raise has no other red to land on.
+ // minutes; the FG12d detach-unknown cell adds 55 s (2x its measured
+ // 27.4 s), moving the fork to 11591 and the step to 204 minutes until
+ // CI measures it: the relational check above tolerates anything up to
+ // step*60, so the value is pinned literally here — an unnoticed raise
+ // has no other red to land on.
if (profile === 'hosted-harness-mysql') {
- expect(forkSeconds).toBe(11536);
+ expect(forkSeconds).toBe(11591);
}
},
);