diff --git a/.github/workflows/sdk-java.yml b/.github/workflows/sdk-java.yml
index 12c650bb60..c0c1d297dc 100644
--- a/.github/workflows/sdk-java.yml
+++ b/.github/workflows/sdk-java.yml
@@ -688,13 +688,15 @@ jobs:
# liveness gates to 6083 s, which moves Verify to 115 minutes, the
# FG13d dropped-reply cancel cell to 6233 s, still inside those 115
# minutes, and the FG12b store-failure class to 6353 s; the FG12f
- # stream-gap class adds 242 s, so the fork moves to 6585 s and Verify to
- # 120 minutes, which wraps its fork + 600 s plus margin; the Broker
- # fault gates keep 10, and admission plus O4 keep per-fork ceilings) plus
- # ~13 minutes for the uncapped setup steps (Checkout.. cgroup
- # delegation, the report checks): 200 + 13. The failover arms run in
+ # stream-gap class adds 242 s, so the fork moves to 6585 s; the FG13a
+ # execute lost-reply cell adds 150 s and the FG13a status lost-reply
+ # cell another 150 s, moving the fork to 6885 s and Verify to 125
+ # minutes to wrap its fork + 600 s plus margin; the Broker fault gates
+ # keep 10, and admission plus O4 keep per-fork ceilings) plus ~13
+ # minutes for the uncapped setup steps (Checkout.. cgroup delegation,
+ # the report checks): 205 + 13. The failover arms run in
# hosted-failover-e2e.
- timeout-minutes: 213
+ timeout-minutes: 218
services:
mysql:
image: 'mysql:8.4.6'
@@ -783,8 +785,8 @@ 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
- # 6585 s, so the step wraps to fork + 600 s plus margin.
- timeout-minutes: 120
+ # 6885 s, so the step wraps to fork + 600 s plus margin.
+ timeout-minutes: 125
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-hosted-hook-execute-reply-loss-gates-a.md b/docs/design/2026-10-18-hosted-hook-execute-reply-loss-gates-a.md
new file mode 100644
index 0000000000..3e0e3f3201
--- /dev/null
+++ b/docs/design/2026-10-18-hosted-hook-execute-reply-loss-gates-a.md
@@ -0,0 +1,66 @@
+# Hosted Hook execute reply-loss gates FG13a
+
+[English](2026-10-18-hosted-hook-execute-reply-loss-gates-a.md) | [简体中文](2026-10-18-hosted-hook-execute-reply-loss-gates-a.zh-CN.md)
+
+## Problem and scope
+
+Issue #14449 tracks FG13, the Stage F fault-gate round for the Hosted Hook
+domains. FG13c covered the owner-restart matrix and FG13d the cancellation
+and timeout arms; this slice starts **FG13a, lost replies**, with the arm a
+command Hook pays first: the reply to `hook-execute` is lost after the
+worker admitted the operation.
+
+The dispatch reply is the one place where a lost answer could look like a
+lost call. The worker already owns the receipt, so the retry must resolve
+the same operation through the owner's status instead of dispatching a
+second command.
+
+## Decisions
+
+1. **Reuse the FG13d rig and broker proxy.** The class borrows the Hook-only
+ definition, the packaged Harness, the durable worker and the forwarding
+ proxy from the FG13d stack base; only the `hook-execute` control reply is
+ dropped, and every other call passes through.
+2. **The retry is a status poll, not a second execute.** The Harness falls
+ back to `hook-status` on the record's own `runtimeSessionId` when the
+ dispatch reply is lost, which is what keeps the physical call at one.
+3. **Two cells, one fixture each.** Both hold a solo command for one second
+ and allow; a dropped dispatch reply must resolve through status, and a
+ dropped status reply must stay unknown until a later poll of the same
+ owner settles it. Neither may add a second physical call.
+
+## Case
+
+| Case | Inject | Expect |
+| -------------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `execute-reply-lost` | A `PreToolUse` command Hook runs; the broker proxy drops the `hook-execute` control reply after the worker admitted it. | Exactly one physical call; the retry resolves the same operation through the owner's status; the record settles `settled` with `outcome=success`; the allow reaches the model once; the proxy log records the drop. |
+| `status-reply-lost` | The same solo command; the broker proxy drops the reply to a `hook-status` poll after the dispatch. | The dropped poll cannot prove the end, so the record first reports `recovery_blocked` / `outcome_unknown`; a later poll of the same owner settles it `success`, with exactly one physical call and the drop in the proxy log. |
+
+## Mutation witness
+
+Each witness must turn the cell red before the slice merges.
+
+- `execute-reply-lost`: break the exercised fallback — make the dropped
+ dispatch reply answer `outcome_unknown` instead of looking up the receipt
+ (for example, remove the `hook-status` fallback in `fire`). The record
+ then never settles and the `settled` assertion fails. The execute
+ operation-id deduplication is a separate witness: explicitly replay the
+ `hook-execute` operation.
+- `status-reply-lost`: drop the honest unknown commit in the `fire` loop
+ (the branch that records `recovery_blocked` / `outcome_unknown` when the
+ poll cannot prove the end). The record stays `running`, so the state
+ assertion fails.
+
+## Non-goals
+
+The rest of FG13a — `hook-catalog`, `hook-cancel`, the store acks of the
+`dispatch_started` and result commits, and a lost HTTP Hook response — stays
+on #14449. Also out: FG13b, FG13e, FG13f, local non-managed
+Hooks, Windows/macOS and #13133's design items.
+
+## Validation
+
+The full `hosted-harness-mysql` lane on Linux with MySQL 8.4 and a delegated
+cgroup v2 root is the evidence. Checkstyle, the CI fork-budget guard and the
+pinned class list move with the new class. A skipped cell is not a passing
+gate.
diff --git a/docs/design/2026-10-18-hosted-hook-execute-reply-loss-gates-a.zh-CN.md b/docs/design/2026-10-18-hosted-hook-execute-reply-loss-gates-a.zh-CN.md
new file mode 100644
index 0000000000..90606a309d
--- /dev/null
+++ b/docs/design/2026-10-18-hosted-hook-execute-reply-loss-gates-a.zh-CN.md
@@ -0,0 +1,53 @@
+# Hosted Hook execute 丢回复门禁 FG13a
+
+[English](2026-10-18-hosted-hook-execute-reply-loss-gates-a.md) | [简体中文](2026-10-18-hosted-hook-execute-reply-loss-gates-a.zh-CN.md)
+
+## 问题与范围
+
+Issue #14449 跟踪 Hosted Hook 两个域的 Stage F 故障门禁轮次。FG13c 覆盖 owner
+重启矩阵,FG13d 覆盖取消与超时;本切片开始 **FG13a(丢回复)**,先补命令 Hook
+最先遇到的一臂:worker 已接收操作之后,`hook-execute` 的回复丢失。
+
+派发回复是唯一会让“丢失的应答”看起来像“丢失的调用”的地方。回执已经在 worker
+手里,因此重试必须通过 owner 的状态解析同一个操作,而不是再派发一条命令。
+
+## 决策
+
+1. **复用 FG13d 的装置与 broker 代理。** 新类沿用只含 Hook 的定义、打包 Harness、
+ durable worker 与转发代理;只丢弃 `hook-execute` 控制调用的回复,其余请求直通。
+2. **重试走状态轮询,而不是第二次 execute。** 派发回复丢失时,Harness 会按记录
+ 自身的 `runtimeSessionId` 回落到 `hook-status`,这正是物理调用保持一次的原因。
+3. **两格、各自一套夹具。** 两格都让 solo 命令保持 1 秒后放行;派发回复丢失必须
+ 通过 status 解析,status 回复丢失必须先保持未知,直到同一 owner 的后续轮询
+ 把它结算。两者都不得增加第二次物理调用。
+
+## 用例
+
+| 用例 | 注入 | 期望 |
+| -------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `execute-reply-lost` | `PreToolUse` 命令 Hook 运行中;broker 代理在 worker 已接收后丢弃 `hook-execute` 的回复。 | 恰好一次物理调用;重试通过 owner 状态解析同一操作;记录结算 `settled` 且 `outcome=success`;放行结果只到达模型一次;代理日志记录这次丢弃。 |
+| `status-reply-lost` | 同样的 solo 命令;broker 代理在派发后丢弃一次 `hook-status` 轮询的回复。 | 丢失的轮询无法证明结束,因此记录先落 `recovery_blocked` / `outcome_unknown`;随后对同一 owner 的轮询把它结算为 `success`,物理调用仍恰好一次,代理日志记录这次丢弃。 |
+
+## 变异见证
+
+每个见证必须在合入前让自己的用例变红。
+
+- `execute-reply-lost`:改破坏这条格真正走到的兜底——让丢失的派发回复直接得到
+ `outcome_unknown`,而不是回落到回执查询(例如去掉 `fire` 里的 `hook-status`
+ 兜底)。记录因此永不结算,`settled` 断言失败。execute 的 operation-id 幂等
+ 是另一个见证:显式重放 `hook-execute`。
+- `status-reply-lost`:去掉 `fire` 循环里如实记录 unknown 的提交分支(轮询无法
+ 证明结束时写 `recovery_blocked` / `outcome_unknown` 的那一支)。记录会停在
+ `running`,状态断言失败。
+
+## 非目标
+
+FG13a 其余部分——`hook-catalog`、`hook-cancel`、`dispatch_started` 与结果提交的
+store ack、以及 HTTP Hook 响应丢失——仍留在 #14449。同样不在范围:FG13b、
+FG13e、FG13f、本地非托管 Hook、Windows/macOS,以及 #13133 的设计项。
+
+## 验证
+
+在 Linux、MySQL 8.4 与委派 cgroup v2 根上跑完整的 `hosted-harness-mysql`
+车道即证据。Checkstyle、CI fork 预算守卫与 pin 名单随新类一起移动。被跳过的用例
+不算通过。
diff --git a/packages/managed-agent-server/pom.xml b/packages/managed-agent-server/pom.xml
index aa1491c211..78a9db1b51 100644
--- a/packages/managed-agent-server/pom.xml
+++ b/packages/managed-agent-server/pom.xml
@@ -465,8 +465,16 @@
locally on the merged base (~242 s
budgeted): the healthy sum becomes
5985 s, so the fork moves to 6585 s
- and the Verify step to 120 minutes. -->
- 6585
+ and the Verify step to 120 minutes.
+ The FG13a execute lost-reply cell
+ (issue #14449) adds 150 s: the healthy
+ sum becomes 6135 s, so the fork moves
+ to 6735 s. The FG13a status lost-reply
+ cell (issue #14449) adds 150 s: the
+ healthy sum becomes 6285 s, so the
+ fork moves to 6885 s and the Verify
+ step to 125 minutes. -->
+ 6885
diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedHookReplyLossFaultGatesIT.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedHookReplyLossFaultGatesIT.java
new file mode 100644
index 0000000000..0f5c2e64b3
--- /dev/null
+++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedHookReplyLossFaultGatesIT.java
@@ -0,0 +1,541 @@
+package com.alibaba.qwen.code.managedagent;
+
+import com.alibaba.qwen.code.daemon.HarnessSessionRef;
+import com.alibaba.qwen.code.managedagent.harness.HarnessConnector;
+import com.alibaba.qwen.code.managedagent.store.ManagedSessionStore;
+import com.alibaba.qwen.code.managedagent.store.WorkspaceStorageGuard;
+import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.apache.commons.codec.digest.DigestUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.condition.EnabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.api.io.CleanupMode;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.boot.builder.SpringApplicationBuilder;
+import org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+// FG13a (issues #14054/#14449): lost replies for the managed command Hook.
+// The dispatch reply is dropped after the worker admitted the operation, so
+// the retry must resolve the same operation through the owner's status, keep
+// exactly one physical call, and apply its single result.
+@EnabledOnOs(OS.LINUX)
+class HostedHookReplyLossFaultGatesIT {
+ private static final String PROFILE = "hosted-workspace-files/1";
+ private static final String TOKEN = "hook-fault-fixture-token";
+ private static final String CAPABILITY = "sha256:" + "a".repeat(64);
+ private static final String PROOF = "FG13_HOOK_TOOL_PROOF";
+ private final ObjectMapper json = new ObjectMapper();
+ private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build();
+ private final String tenant = "hook-fault-" + UUID.randomUUID();
+ private final List modelRequests = new CopyOnWriteArrayList<>();
+ private final AtomicReference modelFailure = new AtomicReference<>();
+ @TempDir(cleanup = CleanupMode.ON_SUCCESS)
+ private Path temporary;
+ private Path cli;
+ private Path workspace;
+ private Path evidence;
+ private Path hookRoot;
+ private String node;
+ private String hookDigest;
+ private int port;
+ private int harnessPort;
+ private int brokerPort;
+ private int boots;
+ private HttpServer model;
+ private ExecutorService modelExecutor;
+ private ServletWebServerApplicationContext spring;
+ private JdbcTemplate jdbc;
+ private Process harness;
+ private HostedSessionRelayForwardingProxy proxy;
+
+ @Test
+ @Timeout(300)
+ void fg13aExecuteReplyLossKeepsOnePhysicalCallOnMySql() throws Exception {
+ boot("60000", "1000", "hook-solo");
+ proxy.arm(HostedSessionRelayForwardingProxy.Rule.drop(
+ (method, path, body) -> path.contains("/control") && body.contains("\"kind\":\"hook-execute\""),
+ "hook-execute-reply"));
+ JsonNode definition = api("POST", "/v1/agents", definition(), "definition", 202);
+ String session = createSession(definition.path("id").asText(), "initial");
+ submit(session, "FG13_EXECUTE_LOSS");
+ waitFor(() -> assertThat(events("hook.call")).hasSize(1));
+ // The dispatch reply is gone, but the retry resolves the same
+ // operation through the owner's status: one physical call, one allow.
+ waitFor(() -> {
+ JsonNode execution = hookExecution(session);
+ assertThat(execution.at("/run/state").asText()).isEqualTo("settled");
+ assertThat(execution.at("/run/execution").asText()).isEqualTo("settled");
+ });
+ assertThat(events("hook.call")).hasSize(1);
+ assertThat(events("hook.allow")).hasSize(1);
+ assertThat(hookResult(session).path("outcome").asText()).isEqualTo("success");
+ waitFor(() -> assertThat(modelRequests.toString()).contains(PROOF));
+ // The completion request must carry exactly one result for the call
+ // the fixture emitted; a duplicate delivery would otherwise pass.
+ long results = 0;
+ for (JsonNode message : modelRequests.getLast().path("messages")) {
+ if ("tool".equals(message.path("role").asText())
+ && "call_FG13_EXECUTE_LOSS".equals(message.path("tool_call_id").asText())) {
+ results++;
+ }
+ }
+ assertThat(results).as("tool results for the emitted call").isEqualTo(1);
+ assertThat(Files.readString(temporary.resolve("proxy.log"))).contains("PROXY dropped hook-execute-reply");
+ assertThat(modelFailure.get()).isNull();
+ }
+
+ private void boot(String timeoutMs, String holdMs, String signalMode) throws Exception {
+ assertThat(System.getProperty("mysql.url")).as("Hook fault gates require disposable MySQL 8.4")
+ .startsWith("jdbc:mysql:");
+ node = System.getProperty("node.executable");
+ assertThat(node).isNotBlank();
+ cli = Path.of(System.getProperty("qwen.cli.entry", "../../dist/cli.js")).toAbsolutePath().normalize();
+ assertThat(cli).isRegularFile();
+ temporary = temporary.toRealPath();
+ workspace = Files.createDirectories(temporary.resolve("workspace/child"));
+ Files.writeString(workspace.resolve("proof.txt"), PROOF);
+ Files.setLastModifiedTime(workspace.getParent(), FileTime.fromMillis(1));
+ Files.createDirectory(temporary.resolve("decoy"));
+ Files.createDirectory(temporary.resolve("broker"),
+ PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")));
+ evidence = temporary.resolve("processes.jsonl");
+ String delegated = System.getenv("QWEN_MANAGED_HOOK_CGROUP_ROOT");
+ assertThat(delegated).as("Run Maven inside a delegated Linux cgroup v2 subtree").isNotBlank();
+ hookRoot = Files.createDirectory(Path.of(delegated).resolve("hook-fault-" + UUID.randomUUID()));
+ Path program = temporary.resolve("hook-fault-process.mjs");
+ try (var resource = getClass().getResourceAsStream("/hosted-hook-fault-process.mjs")) {
+ assertThat(resource).isNotNull();
+ Files.copy(resource, program);
+ }
+ hookDigest = DigestUtils.sha256Hex("hook:" + Files.readString(program));
+ Map hook = new java.util.LinkedHashMap<>(Map.of("hookId", "pre", "eventName", "PreToolUse",
+ "sequential", false, "failClosed", true, "async", false, "config", Map.of("type", "command", "command",
+ quote(node) + " " + quote(program.toString()) + " hook " + quote(evidence.toString())
+ + " " + quote(holdMs) + " " + quote(signalMode), "timeout", Integer.parseInt(timeoutMs))));
+ hook.put("onceKey", null);
+ json.writeValue(temporary.resolve("hooks.json").toFile(), Map.of("version", 1, "catalogs", List.of(
+ Map.of("tenantId", tenant, "workspaceId", "workspace", "catalogId", "fg13-hooks",
+ "catalogRevision", 1, "definitionDigest", catalogDigest(), "hooks", List.of(hook)))));
+ Path worker = temporary.resolve("worker.mjs");
+ Files.writeString(worker, "process.env.QWEN_MANAGED_HOOK_CONFIG="
+ + json.writeValueAsString(temporary.resolve("hooks.json").toString())
+ + "; process.env.QWEN_MANAGED_HOOK_CGROUP_ROOT=" + json.writeValueAsString(hookRoot.toString())
+ + "; process.argv[1]=" + json.writeValueAsString(cli.toString())
+ + "; await import(" + json.writeValueAsString(cli.toUri().toString()) + ");\n");
+ try (var control = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"));
+ var hosted = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"));
+ var runtime = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) {
+ port = control.getLocalPort();
+ harnessPort = hosted.getLocalPort();
+ brokerPort = runtime.getLocalPort();
+ }
+ model = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ modelExecutor = Executors.newVirtualThreadPerTaskExecutor();
+ model.setExecutor(modelExecutor);
+ model.createContext("/v1/chat/completions", this::modelReply);
+ model.start();
+ spring = (ServletWebServerApplicationContext) new SpringApplicationBuilder(ManagedAgentServerApplication.class)
+ .run("--server.address=127.0.0.1", "--server.port=" + port,
+ "--spring.datasource.url=" + System.getProperty("mysql.url"),
+ "--spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver",
+ "--spring.datasource.username=" + System.getProperty("mysql.user"),
+ "--spring.datasource.password=" + System.getProperty("mysql.password", ""),
+ "--qwen.managed-agent.trusted-actor-header=X-Qwen-Actor-Id",
+ "--qwen.managed-agent.definition-tool-profiles=" + PROFILE,
+ "--qwen.managed-agent.session-store.enabled=true",
+ "--qwen.managed-agent.session-store.base-url=http://127.0.0.1:" + port,
+ "--qwen.managed-agent.session-store.workspace-id=unused-global-workspace",
+ "--qwen.managed-agent.session-store.writer-lease-duration=3s",
+ "--qwen.managed-agent.harness.enabled=true",
+ "--qwen.managed-agent.harness.workspace-files-enabled=true",
+ "--qwen.managed-agent.harness.base-url=http://127.0.0.1:" + harnessPort,
+ "--qwen.managed-agent.harness.token=" + TOKEN,
+ "--qwen.managed-agent.harness.capability-digest=" + CAPABILITY,
+ "--qwen.managed-agent.runtime-broker.enabled=true",
+ "--qwen.managed-agent.runtime-broker.port=" + brokerPort,
+ "--qwen.managed-agent.runtime-broker.token=" + TOKEN,
+ "--qwen.managed-agent.runtime-broker.durable-local-process=true",
+ "--qwen.managed-agent.runtime-broker.trusted-local-reboot-recovery=false",
+ "--qwen.managed-agent.runtime-broker.verified-workspace-recovery-enabled=true",
+ "--qwen.managed-agent.runtime-broker.workspace-cwd=" + temporary.resolve("decoy"),
+ "--qwen.managed-agent.runtime-broker.state-directory=" + temporary.resolve("broker"),
+ "--qwen.managed-agent.runtime-broker.credential-key-id=hook-fault",
+ "--qwen.managed-agent.runtime-broker.credential-key=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+ "--qwen.managed-agent.runtime-broker.node-executable=" + node,
+ "--qwen.managed-agent.runtime-broker.worker-entry=" + worker,
+ "--qwen.managed-agent.runtime-broker.workspace-mounts[0].tenant-id=" + tenant,
+ "--qwen.managed-agent.runtime-broker.workspace-mounts[0].storage-id=storage",
+ "--qwen.managed-agent.runtime-broker.workspace-mounts[0].root=" + workspace.getParent());
+ jdbc = spring.getBean(JdbcTemplate.class);
+ spring.getBean(WorkspaceStorageGuard.class).register(tenant, "storage", UUID.randomUUID().toString());
+ jdbc.update("INSERT INTO ma_workspace (tenant_id, workspace_id, workspace_generation, storage_id,"
+ + " display_name, config_ref, policy_ref, state) VALUES (?, 'workspace', 1, 'storage', 'Hooks', ?, ?, 'ACTIVE')",
+ tenant, WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF);
+ jdbc.update("INSERT INTO ma_workspace_access (tenant_id, workspace_id, actor_id, role)"
+ + " VALUES (?, 'workspace', ?, 'OPERATOR')", tenant, "actor".getBytes(StandardCharsets.UTF_8));
+ proxy = HostedSessionRelayForwardingProxy.start("hook-broker", temporary.resolve("proxy.log"));
+ proxy.forwardTo(URI.create("http://127.0.0.1:" + brokerPort));
+ startHarness();
+ }
+
+ private String catalogDigest() {
+ return DigestUtils.sha256Hex(hookDigest + 1);
+ }
+
+ private Map definition() {
+ return Map.of("model", Map.of(), "instructions", "Exercise the pinned Hook gate.",
+ "tools", List.of(Map.of("type", "hosted_profile", "profile", PROFILE)),
+ "permission_policy", Map.of("approval_mode", "yolo"),
+ "hook_catalog", Map.of("catalog_id", "fg13-hooks", "catalog_revision", 1,
+ "definition_digest", catalogDigest()));
+ }
+
+ private String createSession(String agent, String key) throws Exception {
+ return api("POST", "/v1/agents/sessions", Map.of("agent_id", agent, "input", List.of(),
+ "workspace", Map.of("workspace_id", "workspace", "cwd_relative", "child")), key, 202)
+ .path("id").asText();
+ }
+
+ private String submit(String session, String prompt) throws Exception {
+ return api("POST", "/v1/agents/sessions/" + session + "/events", Map.of("type", "agent.session.input.message",
+ "input", List.of(Map.of("type", "input_text", "text", prompt))), prompt, 202).path("turn_id").asText();
+ }
+
+ private JsonNode hookExecution(String session) {
+ List records = HostedGateTestSupport.extensionRecords(jdbc, tenant, session, "hook_execution").stream()
+ .filter(record -> !"__plan__".equals(record.path("hookId").asText()))
+ .toList();
+ assertThat(records).as("hook execution records").hasSize(1);
+ assertThat(records.getFirst().path("eventName").asText()).isEqualTo("PreToolUse");
+ return records.getFirst();
+ }
+
+ private JsonNode hookResult(String session) throws Exception {
+ JsonNode ref = hookExecution(session).path("resultRef");
+ assertThat(ref.isNull()).as("hook result ref").isFalse();
+ String body = jdbc.queryForObject("SELECT CONVERT(inline_bytes USING utf8mb4) FROM ma_resource"
+ + " WHERE session_scope_key = ? AND resource_id = ? AND tenant_id = ? AND session_id = ?",
+ String.class, ManagedSessionStore.sessionScopeKey(tenant, session),
+ ref.path("resourceId").asText(), tenant, session);
+ return json.readTree(body);
+ }
+
+ @Test
+ @Timeout(300)
+ void fg13aStatusReplyLossStaysUnknownUntilTheNextPollOnMySql() throws Exception {
+ boot("60000", "1000", "hook-solo");
+ proxy.arm(HostedSessionRelayForwardingProxy.Rule.drop(
+ (method, path, body) -> path.contains("/control") && body.contains("\"kind\":\"hook-status\""),
+ "hook-status-reply"));
+ JsonNode definition = api("POST", "/v1/agents", definition(), "definition", 202);
+ String session = createSession(definition.path("id").asText(), "initial");
+ submit(session, "FG13_STATUS_LOSS");
+ waitFor(() -> assertThat(events("hook.call")).hasSize(1));
+ // The dropped poll answer cannot prove the end, so the gate records
+ // the honest unknown instead of guessing.
+ waitFor(() -> {
+ JsonNode execution = hookExecution(session);
+ assertThat(execution.at("/run/state").asText()).isEqualTo("recovery_blocked");
+ assertThat(execution.at("/run/execution").asText()).isEqualTo("outcome_unknown");
+ });
+ assertThat(events("hook.call")).hasSize(1);
+ // A later poll of the same owner resolves the receipt the worker
+ // still holds, without a second physical call.
+ String executionId = hookExecution(session).path("hookExecutionId").asText();
+ HarnessAttach attach = attachment(spring.getBean(HarnessConnector.class), session);
+ assertThat(attach).as("Session attachment").isNotNull();
+ waitFor(() -> {
+ var status = hookStatus(session, executionId, attach);
+ assertThat(status.statusCode()).as("hook status: %s", status.body()).isEqualTo(200);
+ assertThat(json.readTree(status.body()).path("state").asText()).isEqualTo("settled");
+ });
+ waitFor(() -> assertThat(hookExecution(session).at("/run/state").asText()).isEqualTo("settled"));
+ assertThat(hookResult(session).path("outcome").asText()).isEqualTo("success");
+ // The allow lands when the one-second hold ends, after the first
+ // unknown observation, so it is asserted only past the settlement.
+ waitFor(() -> assertThat(events("hook.allow")).hasSize(1));
+ assertThat(events("hook.call")).hasSize(1);
+ assertThat(Files.readString(temporary.resolve("proxy.log"))).contains("PROXY dropped hook-status-reply");
+ assertThat(modelFailure.get()).isNull();
+ }
+
+ // The Harness status route identifies the caller by the client id its
+ // attach minted and the process generation it attached to; the connector
+ // keeps both on the private attachment ref.
+ private record HarnessAttach(String clientId, String bootId) {}
+
+ private HarnessAttach attachment(HarnessConnector connector, String session) throws Exception {
+ Field field = connector.getClass().getDeclaredField("attachments");
+ field.setAccessible(true);
+ for (Object value : ((Map, ?>) field.get(connector)).values()) {
+ if (value instanceof HarnessSessionRef ref && session.equals(ref.getHarnessSessionId())) {
+ return new HarnessAttach(ref.getHarnessClientId(), ref.getHarnessBootId());
+ }
+ }
+ return null;
+ }
+
+ private HttpResponse hookStatus(String session, String executionId, HarnessAttach attach) throws Exception {
+ var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + harnessPort
+ + "/session/" + session + "/hooks/operations/" + executionId))
+ .timeout(Duration.ofSeconds(10)).header("Authorization", "Bearer " + TOKEN)
+ .header("X-Qwen-Client-Id", attach.clientId())
+ .header("X-Qwen-Harness-Protocol-Version", "1")
+ .header("X-Qwen-Harness-Boot-Id", attach.bootId()).GET().build();
+ return http.send(request, HttpResponse.BodyHandlers.ofString());
+ }
+
+ private void startHarness() throws Exception {
+ Path home = Files.createDirectory(temporary.resolve("home-" + ++boots));
+ Path config = Files.createDirectory(home.resolve(".qwen"));
+ String modelUrl = "http://127.0.0.1:" + model.getAddress().getPort() + "/v1";
+ json.writeValue(config.resolve("settings.json").toFile(), Map.of("security", Map.of("auth", Map.of("selectedType", "openai")),
+ "telemetry", Map.of("enabled", false), "model", Map.of("name", "hook-fault-fixture"),
+ "modelProviders", Map.of("openai", List.of(Map.of("id", "hook-fault-fixture", "envKey", "OPENAI_API_KEY", "baseUrl", modelUrl)))));
+ ProcessBuilder builder = new ProcessBuilder(node, cli.toString(), "serve", "--profile", "hosted-harness",
+ "--http-bridge", "--hostname", "127.0.0.1", "--port", Integer.toString(harnessPort), "--require-auth",
+ "--no-web", "--workspace", temporary.resolve("decoy").toString(), "--managed-runtime-broker-url",
+ proxy.baseUrl(), "--managed-runtime-broker-token", TOKEN)
+ .directory(temporary.resolve("decoy").toFile()).redirectErrorStream(true)
+ .redirectOutput(temporary.resolve("harness-" + boots + ".log").toFile());
+ var env = builder.environment();
+ env.clear();
+ env.put("PATH", System.getenv("PATH"));
+ env.putAll(Map.of("HOME", home.toString(), "QWEN_HOME", config.toString(), "QWEN_RUNTIME_DIR", home.resolve("runtime").toString(),
+ "OPENAI_API_KEY", "fixture-only", "OPENAI_BASE_URL", modelUrl, "QWEN_SERVER_TOKEN", TOKEN,
+ "QWEN_HOSTED_HARNESS_CAPABILITY_DIGEST", CAPABILITY,
+ "QWEN_CODE_MODELS_DEV_REFRESH", "off"));
+ for (String key : List.of("QWEN_CODE_SYSTEM_SETTINGS_PATH", "QWEN_CODE_SYSTEM_DEFAULTS_PATH", "QWEN_CODE_TRUSTED_FOLDERS_PATH")) {
+ env.put(key, home.resolve(key + ".json").toString());
+ }
+ harness = builder.start();
+ waitFor(() -> {
+ assertThat(harness.isAlive()).as("Harness log: %s", temporary.resolve("harness-" + boots + ".log")).isTrue();
+ assertThat(http.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + harnessPort + "/capabilities"))
+ .timeout(Duration.ofSeconds(2)).header("Authorization", "Bearer " + TOKEN).build(),
+ HttpResponse.BodyHandlers.discarding()).statusCode()).isEqualTo(200);
+ });
+ }
+
+ private void modelReply(HttpExchange exchange) throws IOException {
+ try {
+ JsonNode request = json.readTree(exchange.getRequestBody());
+ modelRequests.add(request);
+ String prompt = "";
+ List results = new ArrayList<>();
+ for (JsonNode message : request.path("messages")) {
+ if ("user".equals(message.path("role").asText()) && message.path("content").toString().contains("FG13_")) {
+ String text = message.path("content").toString();
+ var matcher = java.util.regex.Pattern.compile("FG13_[A-Z_]+").matcher(text);
+ prompt = "";
+ while (matcher.find()) {
+ prompt = matcher.group();
+ }
+ results.clear();
+ } else if ("tool".equals(message.path("role").asText())) {
+ results.add(message);
+ }
+ }
+ assertThat(prompt).isNotBlank();
+ List names = new ArrayList<>();
+ request.path("tools").forEach(tool -> names.add(tool.at("/function/name").asText()));
+ assertThat(names).contains("read_file");
+ boolean call = results.isEmpty()
+ && List.of("FG13_EXECUTE_LOSS", "FG13_STATUS_LOSS").contains(prompt);
+ var chunk = json.createObjectNode().put("id", "hook-fault").put("object", "chat.completion.chunk")
+ .put("created", 0).put("model", "hook-fault-fixture");
+ var choice = chunk.putArray("choices").addObject().put("index", 0);
+ var delta = choice.putObject("delta").put("role", "assistant");
+ if (call) {
+ delta.putArray("tool_calls").addObject().put("index", 0).put("id", "call_" + prompt)
+ .put("type", "function").putObject("function").put("name", "read_file")
+ .put("arguments", json.writeValueAsString(Map.of("file_path", "proof.txt")));
+ } else {
+ delta.put("content", "FG13_DONE_" + prompt);
+ }
+ choice.putNull("finish_reason");
+ String first = "data: " + json.writeValueAsString(chunk) + "\n\n";
+ choice.putObject("delta");
+ choice.put("finish_reason", call ? "tool_calls" : "stop");
+ byte[] response = (first + "data: " + json.writeValueAsString(chunk) + "\n\ndata: [DONE]\n\n")
+ .getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
+ exchange.sendResponseHeaders(200, response.length);
+ exchange.getResponseBody().write(response);
+ } catch (Exception | AssertionError failure) {
+ modelFailure.compareAndSet(null, failure);
+ } finally {
+ exchange.close();
+ }
+ }
+
+ private boolean running(JsonNode process) throws Exception {
+ if (!Files.readString(Path.of("/proc/sys/kernel/random/boot_id")).strip().equals(process.path("bootId").asText())) {
+ return false;
+ }
+ Path stat = Path.of("/proc", process.path("pid").asText(), "stat");
+ String value;
+ try {
+ value = Files.readString(stat);
+ } catch (NoSuchFileException stopped) {
+ return false;
+ }
+ String[] fields = value.substring(value.lastIndexOf(')') + 2).split(" ");
+ return !"Z".equals(fields[0]) && fields[19].equals(process.path("startTicks").asText());
+ }
+
+ private Path cgroup(JsonNode process) {
+ Path path = Path.of("/sys/fs/cgroup" + process.path("cgroup").asText().substring(3));
+ assertThat(path).startsWith(hookRoot);
+ return path;
+ }
+
+ private List events(String kind) throws Exception {
+ if (!Files.exists(evidence)) {
+ return List.of();
+ }
+ List result = new ArrayList<>();
+ for (String line : Files.readAllLines(evidence)) {
+ JsonNode event = json.readTree(line);
+ if (kind.equals(event.path("event").asText())) {
+ result.add(event);
+ }
+ }
+ return result;
+ }
+
+ private JsonNode api(String method, String path, Object body, String key, int... expected) throws Exception {
+ var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path))
+ .timeout(Duration.ofSeconds(60)).header("X-Qwen-Tenant-Id", tenant).header("X-Qwen-Actor-Id", "actor")
+ .header("Content-Type", "application/json").method(method, body == null ? HttpRequest.BodyPublishers.noBody()
+ : HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body)));
+ if (key != null) {
+ request.header("Idempotency-Key", key);
+ }
+ var response = http.send(request.build(), HttpResponse.BodyHandlers.ofString());
+ assertThat(response.statusCode()).as("%s %s: %s", method, path, response.body()).isIn(java.util.Arrays.stream(expected).boxed().toList());
+ return json.readTree(response.body());
+ }
+
+ private void waitFor(org.junit.jupiter.api.function.Executable assertion) {
+ await().atMost(Duration.ofSeconds(60)).ignoreException(java.net.ConnectException.class).failFast(() -> {
+ if (modelFailure.get() != null) {
+ throw new AssertionError("Model fixture: " + temporary, modelFailure.get());
+ }
+ }).untilAsserted(assertion::execute);
+ }
+
+ private static String quote(String text) {
+ return "'" + text.replace("'", "'\"'\"'") + "'";
+ }
+
+ @AfterEach
+ void stop() {
+ assertAll(this::saveEvidence,
+ () -> { if (model != null) {
+ model.stop(0);
+ } },
+ () -> { if (modelExecutor != null) {
+ modelExecutor.close();
+ } },
+ () -> { if (harness != null) {
+ harness.destroyForcibly();
+ assertThat(harness.waitFor(10, TimeUnit.SECONDS)).isTrue();
+ } },
+ () -> { if (evidence != null && Files.exists(evidence)) {
+ for (String kind : List.of("hook.call", "hook.child")) {
+ for (JsonNode process : events(kind)) {
+ if (running(process)) {
+ ProcessHandle.of(process.path("pid").asLong()).orElseThrow().destroyForcibly();
+ await().atMost(Duration.ofSeconds(10)).until(() -> !running(process));
+ }
+ }
+ }
+ } },
+ () -> { if (spring != null) {
+ spring.close();
+ } },
+ () -> HostedDurableWorkers.stop(temporary.resolve("broker"), json),
+ () -> {
+ if (hookRoot == null) {
+ return;
+ }
+ try (var units = Files.list(hookRoot)) {
+ for (Path unit : units.toList()) {
+ if (Files.exists(unit.resolve("cgroup.kill"))) {
+ Files.writeString(unit.resolve("cgroup.kill"), "1");
+ }
+ }
+ }
+ try (var entries = Files.walk(hookRoot)) {
+ for (Path entry : entries.filter(Files::isDirectory).sorted(Comparator.reverseOrder()).toList()) {
+ Files.delete(entry);
+ }
+ }
+ }, () -> {
+ if (proxy != null) {
+ proxy.close();
+ }
+ }, http::close);
+ }
+
+ private void saveEvidence() throws Exception {
+ if (temporary == null) {
+ return;
+ }
+ Path destination = Files.createDirectories(Path.of("target/failsafe-reports/hosted-hook-fault-evidence", tenant));
+ System.out.println("HOSTED_HOOK_FAULT_EVIDENCE " + destination.toAbsolutePath());
+ json.writeValue(destination.resolve("model-requests.json").toFile(), modelRequests);
+ for (String name : List.of("processes.jsonl", "harness-1.log", "harness-2.log", "proxy.log")) {
+ Path source = temporary.resolve(name);
+ if (Files.exists(source)) {
+ Files.copy(source, destination.resolve(source.getFileName()));
+ }
+ }
+ if (jdbc != null) {
+ json.writeValue(destination.resolve("bindings.json").toFile(), jdbc.queryForList(
+ "SELECT binding_id, runtime_generation, binding_state, isolation_key FROM ma_runtime_binding WHERE tenant_id = ?", tenant));
+ json.writeValue(destination.resolve("hook-executions.json").toFile(),
+ jdbc.queryForList("SELECT record_id, domain, state, runtime_state FROM ma_session_record"
+ + " WHERE tenant_id = ? AND domain = 'hook_execution'", tenant));
+ }
+ }
+}
diff --git a/scripts/tests/hosted-process-ci.test.js b/scripts/tests/hosted-process-ci.test.js
index ebdef398ad..b631e9cbab 100644
--- a/scripts/tests/hosted-process-ci.test.js
+++ b/scripts/tests/hosted-process-ci.test.js
@@ -415,9 +415,9 @@ describe('Hosted real-process gates', () => {
});
it.each([
- // Step ceilings today (120 + 10 + 20 + 50): Verify, the Broker fault
+ // Step ceilings today (125 + 10 + 20 + 50): Verify, the Broker fault
// gates, admission and O4. The failover arms moved to their own job.
- ['hosted-harness-mysql', 200, 213],
+ ['hosted-harness-mysql', 205, 218],
// Fifteen failover arms at 10 each.
['hosted-failover-e2e', 150, 165],
])(
@@ -609,13 +609,15 @@ describe('Hosted real-process gates', () => {
// 6233; the FG12b store-failure class adds ~120 s (49.6 s measured
// locally on JDK 21 + MySQL 8.0.46), so the fork moves to 6353 and the
// Verify step to 116 minutes; the FG12f stream-gap class adds ~242 s
- // (121.1 s measured on the merged base), so the fork moves to 6585 and
- // the Verify step to 120 minutes):
+ // (121.1 s measured on the merged base), so the fork moves to 6585;
+ // the FG13a execute lost-reply cell adds 150 s, so the fork moves to
+ // 6735; the FG13a status lost-reply cell adds 150 s, so the fork moves
+ // to 6885 and the Verify step to 125 minutes):
// 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(6585);
+ expect(forkSeconds).toBe(6885);
}
},
);
@@ -669,11 +671,13 @@ describe('Hosted real-process gates', () => {
// class adds ~120s (49.6s measured on JDK 21 + MySQL 8.0.46), so it
// becomes 5743s; the FG12f stream-gap class (HostedMcpStreamGapIT, three
// cases, three cold boots) measures 121.1s locally on the merged base
- // (~242s budgeted), so it becomes 5985s. The largest ceiling (600s) plus
+ // (~242s budgeted), so it becomes 5985s; the FG13a execute lost-reply
+ // cell adds 150s, so it becomes 6135s; the FG13a status lost-reply cell
+ // adds 150s, so it becomes 6285s. The largest ceiling (600s) plus
// that healthy sum still fits the fork. A larger ceiling lets failsafe
// kill the fork mid-run, and the classes left unrun never report, failing
// the gate undiagnosably (#13780).
- const hostedForkHealthySeconds = 5985;
+ const hostedForkHealthySeconds = 6285;
const hostedForkSeconds = () =>
Number(
read('packages/managed-agent-server/pom.xml')
@@ -742,6 +746,7 @@ describe('Hosted real-process gates', () => {
'HostedHarnessMySqlIT',
'HostedHookCancelFaultGatesIT',
'HostedHookFaultGatesIT',
+ 'HostedHookReplyLossFaultGatesIT',
'HostedMcpReplyLossIT',
'HostedMcpRestartFaultGatesIT',
'HostedMcpStoreFaultGatesIT',