From 4da84e928cabb67957db22a7a597958dcc8d1e44 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 30 Sep 2026 22:29:05 +0900 Subject: [PATCH 01/73] feat(managed-agent): admit later Turns of a Workspace-bound Session for its creator G0 admits only the initial file-tool Turn created with a Workspace-bound Session; every later public submit is refused with workspace_unavailable, so a Hosted Session cannot hold a conversation. Admit a later Turn when the deployment's Workspace-files opt-in is on and the submitter created the Session. Execution already authorizes each Turn against the creator's Workspace grants (WorkspaceExecutionStore.authorize), so letting another actor submit would run tools under the creator's authority; every other actor keeps the existing refusal (404 without read access, 409 otherwise). The store admits a bound Session's later Turn only under the same opt-in. Cancel, rename, lifecycle and cwd operations stay gated. - ManagedWorkspaceRegistry.createdSession looks up the creator in managed_workspace_create_command. - HostedPublicWorkspaceIT: a reader who is not the creator is refused; the creator's second Turn runs write, edit and read again through the real Broker and worker and completes. - ManagedWorkspaceAdmissionTest: the enabled store admits the later Turn and the creator lookup distinguishes actors and tenants. - The Workspace-binding capability description (contract and generated WebShell types), the README and both G0 design documents describe the new boundary. The contract version is left for #13101, which takes 1.25. Not built or run locally. --- ...09-29-hosted-public-workspace-admission.md | 9 +++- ...hosted-public-workspace-admission.zh-CN.md | 4 +- .../sdk-java/managed-agent-server/README.md | 7 ++- .../service/ManagedAgentService.java | 19 +++++++- .../managedagent/store/ManagedAgentStore.java | 4 +- .../store/ManagedWorkspaceRegistry.java | 23 +++++++++ .../managed-agent-public-api.openapi.json | 4 +- .../managedagent/HostedPublicWorkspaceIT.java | 47 +++++++++++++++++-- .../ManagedWorkspaceAdmissionTest.java | 45 ++++++++++++++++++ .../managed/generated/managed-agent-api.ts | 2 +- 10 files changed, 150 insertions(+), 14 deletions(-) diff --git a/docs/design/2026-09-29-hosted-public-workspace-admission.md b/docs/design/2026-09-29-hosted-public-workspace-admission.md index 036fb64541d..bb8551c2b3e 100644 --- a/docs/design/2026-09-29-hosted-public-workspace-admission.md +++ b/docs/design/2026-09-29-hosted-public-workspace-admission.md @@ -18,6 +18,12 @@ service. Later submit, cancel, rename, lifecycle and cwd operations retain their existing Workspace gates. Discovery continues to advertise only Workspace binding, not complete Workspace execution support. No UI changes are required. +A follow-up admits later Turns for the Session's creator under the same opt-in. +Execution authorizes every Turn against the creator's Workspace grants, so any +other actor, and every deployment without the opt-in, keeps the existing +`workspace_unavailable` refusal. Cancel, rename, lifecycle and cwd operations +remain gated. + ## Decisions - A deployment explicitly enables `harness.workspace-files-enabled` (environment @@ -85,7 +91,8 @@ bundle, focused tests and two clean diff audits precede completion. G0 lives under #12952 for this implementation; moving its tracking to D or W does not change the contract. This does not settle G3 scope. Shell, approvals, D8 -AgentDefinition, public profile selection, later Turns, lifecycle enablement, +AgentDefinition, public profile selection, later Turns (since admitted for the +creator, above), lifecycle enablement, distributed provisioning and W0e/G1–G3 recovery remain separate. The existing `EmbeddedRuntimeBroker` is a production component and remains allowed; the E2E must not replace it or bypass admission with direct store calls. diff --git a/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md b/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md index e2af8e269c5..85fbf0f3cf0 100644 --- a/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md +++ b/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md @@ -10,6 +10,8 @@ Hosted Read/Write/Edit 已能经过生产 Broker 和 worker 执行,但目前 G0 开放随创建会话准入的一次初始文件工具轮次,使用现有公开 REST 路由及共享 service 的 WebShell 创建适配器。后续提交、取消、重命名、生命周期与 cwd 操作保持现有 Workspace 门禁。发现接口仍仅广播 Workspace 绑定能力,不宣称完整的 Workspace 执行支持。无需修改 UI。 +后续改动在同一开关下为会话创建者开放后续 Turn。执行时每个 Turn 都按创建者的 Workspace 授权校验,因此其他 actor 以及未开启该开关的部署仍得到现有的 `workspace_unavailable` 拒绝。取消、重命名、生命周期与 cwd 操作仍保持门禁。 + ## 决策 - 部署显式启用 `harness.workspace-files-enabled`(环境变量 `QWEN_MANAGED_AGENT_WORKSPACE_FILES_ENABLED`),默认关闭。它要求 Hosted Harness、HTTP Session Store,以及同机、会话隔离的 local-process Broker。原有无绑定的无工具会话行为不变。关闭开关后拒绝携带输入的创建请求(包括重试);空输入的绑定会话创建和读取保持可用。 @@ -42,4 +44,4 @@ SDK 序列化、connector、store/准入及 coordinator 的定向测试覆盖 cr ## 边界与待定事项 -本次实现把 G0 放在 #12952 下;以后调整到 D 或 W 跟踪不改变契约,也不决定 G3 的范围。Shell、审批、D8 AgentDefinition、公开 profile 选择、后续 Turn、生命周期开放、分布式供给及 W0e/G1–G3 恢复均另行推进。现有 `EmbeddedRuntimeBroker` 是生产组件,可以继续使用;E2E 不得替换它或通过直接调用 store 绕过准入。 +本次实现把 G0 放在 #12952 下;以后调整到 D 或 W 跟踪不改变契约,也不决定 G3 的范围。Shell、审批、D8 AgentDefinition、公开 profile 选择、后续 Turn(此后已对创建者开放,见上文)、生命周期开放、分布式供给及 W0e/G1–G3 恢复均另行推进。现有 `EmbeddedRuntimeBroker` 是生产组件,可以继续使用;E2E 不得替换它或通过直接调用 store 绕过准入。 diff --git a/packages/sdk-java/managed-agent-server/README.md b/packages/sdk-java/managed-agent-server/README.md index 939a8ff6bb5..a644a323b14 100644 --- a/packages/sdk-java/managed-agent-server/README.md +++ b/packages/sdk-java/managed-agent-server/README.md @@ -425,8 +425,11 @@ Foreground Shell may create detached descendants. Use this only with trusted local workloads. The opt-in W0e recovery above handles trusted host reboot; it does not provide physical isolation or recovery after worker-only death. Public bound Turn admission is limited to the opt-in initial file Turn described -in G0 above. Later public submit, cancel and lifecycle operations remain gated; -the private Shell profile is not enabled through public creation. +in G0 above and to later Turns submitted by the Session's creator under the same +opt-in. Later Turns run under the creator's Workspace grants, so any other actor +keeps the `workspace_unavailable` refusal. Public cancel and lifecycle +operations remain gated; the private Shell profile is not enabled through +public creation. See the bilingual [execution design](../../../docs/design/2026-09-26-managed-workspace-execution.md) for the exact boundary. diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java index 9170de07a52..7fec012f2a2 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java @@ -180,7 +180,7 @@ public CommandAdmission submitTurn(String tenantId, String actorId, String idempotencyKey, String sessionId, List blocks) { validateIdempotencyKey(idempotencyKey); - requireLegacyWorkspace(tenantId, actorId, sessionId); + requireSubmitter(tenantId, actorId, sessionId); requireHarness(); List> input = input(blocks, true); String requestDigest = digests.digest(Map.of( @@ -637,6 +637,23 @@ String lifecycleDigest(String sessionId, String operation) { "sessionId", sessionId, "operation", operation)); } + // Later Turns of a Workspace-bound Session run under the creator's + // Workspace grants (WorkspaceExecutionStore.authorize), so only the + // creator may submit them, and only with Workspace files enabled. + // Everyone else keeps the existing refusal. + private void requireSubmitter(String tenantId, String actorId, + String sessionId) { + SessionRecord session = store.requireSession(tenantId, sessionId); + if (session.workspace() != null + && harness.isWorkspaceFilesAvailable() + && workspaces.canRead(tenantId, actorId, + session.workspace().getWorkspaceId()) + && workspaces.createdSession(tenantId, actorId, sessionId)) { + return; + } + requireLegacyWorkspace(tenantId, actorId, sessionId); + } + void requireLegacyWorkspace(String tenantId, String actorId, String sessionId) { SessionRecord session = store.requireSession(tenantId, sessionId); diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java index fac892f2dea..5aa9e7609ac 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java @@ -410,7 +410,9 @@ public Admission insertTurnCommand(String tenantId, String operation, String idempotencyKey, String requestDigest, String sessionId, List> input, String payloadDigest) { SessionRecord session = requireSessionForUpdate(tenantId, sessionId); - if (session.workspace() != null) { + // A bound Session's later Turn needs the same deployment opt-in as + // its initial one; the service admits only the Session's creator. + if (session.workspace() != null && !workspaceFilesEnabled) { throw workspaceExecutionUnavailable(); } Optional existing = findCommand(tenantId, operation, diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedWorkspaceRegistry.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedWorkspaceRegistry.java index 612bbee66d8..1893192c2f8 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedWorkspaceRegistry.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedWorkspaceRegistry.java @@ -26,6 +26,29 @@ public ManagedWorkspaceRegistry(JdbcTemplate jdbc) { this.jdbc = jdbc; } + /** + * Whether the actor created this Workspace-bound Session. Its later Turns + * run under the creator's grants, so only the creator may submit them. + */ + public boolean createdSession(String tenantId, String actorId, + String sessionId) { + if (actorId == null || actorId.isEmpty()) { + return false; + } + byte[] key; + try { + key = actorKey(tenantId, actorId); + } catch (IllegalArgumentException error) { + return false; + } + return !jdbc.queryForList("SELECT 1 FROM managed_workspace_create_command" + + " WHERE tenant_id = ? AND session_id = ?" + + " AND CAST(CONCAT(tenant_id, '!') AS BINARY(513))" + + " = CAST(CONCAT(?, '!') AS BINARY(513))" + + " AND actor_id = ?", + Integer.class, tenantId, sessionId, tenantId, key).isEmpty(); + } + public boolean canRead(String tenantId, String actorId, String workspaceId) { if (actorId == null || actorId.isEmpty()) { diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json index 5e64feab95e..9b687a5f0fa 100644 --- a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json +++ b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json @@ -4280,7 +4280,7 @@ }, "workspace_binding": { "type": "boolean", - "description": "Supports authorized Workspace discovery, Session creation, and saved binding read-back. This capability does not advertise execution readiness. Deployments may separately opt in to an initial Workspace Read/Write/Edit Turn at creation; later submit, cancel and lifecycle operations remain gated." + "description": "Supports authorized Workspace discovery, Session creation, and saved binding read-back. This capability does not advertise execution readiness. Deployments may separately opt in to an initial Workspace Read/Write/Edit Turn at creation; the Session creator may submit later Turns under the same opt-in, while cancel and lifecycle operations remain gated." } }, "x-qwen-implementation-status": "partial" @@ -4513,7 +4513,7 @@ }, "workspaceBinding": { "type": "boolean", - "description": "Supports authorized Workspace discovery, Session creation, and saved binding read-back. This capability does not advertise execution readiness. Deployments may separately opt in to an initial Workspace Read/Write/Edit Turn at creation; later submit, cancel and lifecycle operations remain gated." + "description": "Supports authorized Workspace discovery, Session creation, and saved binding read-back. This capability does not advertise execution readiness. Deployments may separately opt in to an initial Workspace Read/Write/Edit Turn at creation; the Session creator may submit later Turns under the same opt-in, while cancel and lifecycle operations remain gated." } }, "x-qwen-implementation-status": "partial" diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java index d2904be8679..03491538384 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java @@ -142,10 +142,39 @@ void publicCreationRunsFilesThroughProductionWorkspaceBinding() throws Exception assertThat(request("POST", route, changed, workspace, "actor", 409).path("error").path("code").asText()) .isEqualTo("idempotency_conflict"); request("GET", "/v1/agents/sessions/" + session, null, null, "other", 404); - request("POST", "/v1/agents/sessions/" + session + "/events", - Map.of("type", "agent.session.input.message", "input", List.of(input)), "later", "actor", 409); + // A later Turn runs under the creator's grants: another actor who can read the + // Session keeps the refusal, and the creator's second Turn runs the file tools again. + jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read," + + " can_create) VALUES (?, ?, ?, TRUE, TRUE)", tenant, workspace, + "reader".getBytes(StandardCharsets.UTF_8)); + Map later = Map.of("type", "agent.session.input.message", "input", + List.of(Map.of("type", "input_text", "text", "G0_AGAIN"))); + assertThat(request("POST", "/v1/agents/sessions/" + session + "/events", later, + "reader-later-" + workspace, "reader", 409).path("error").path("code").asText()) + .isEqualTo("workspace_unavailable"); + String laterTurn = request("POST", "/v1/agents/sessions/" + session + "/events", later, + "later-" + workspace, "actor", 202).path("turn_id").asText(); + assertThat(laterTurn).isNotBlank(); + await().atMost(Duration.ofSeconds(35)).failFast(() -> { + String status = jdbc.queryForObject("SELECT status FROM managed_agent_turn" + + " WHERE session_id = ? AND turn_id = ?", String.class, session, laterTurn); + if ("FAILED".equals(status)) { + throw new AssertionError("Later Turn failed. Harness: " + + Files.readString(temporary.resolve("harness.log"))); + } + }).untilAsserted(() -> { + assertThat(modelFailure.get()).isNull(); + assertThat(jdbc.queryForObject("SELECT status FROM managed_agent_turn" + + " WHERE session_id = ? AND turn_id = ?", String.class, session, laterTurn)) + .isEqualTo("COMPLETED"); + }); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM qwen_tool_execution WHERE harness_session_id = ?", + Long.class, session)).isEqualTo(executions * 2); + assertThat(modelRequests).hasSize(requests + 4); + assertThat(Files.readString(roots.get(index).resolve("child/proof.txt"))).isEqualTo("after"); + assertThat(decoy.resolve("proof.txt")).doesNotExist(); } - assertThat(modelRequests).hasSize(8); + assertThat(modelRequests).hasSize(16); assertThat(modelFailure.get()).isNull(); Map denied = Map.of("agent_id", "qwen-code", "workspace", Map.of("workspace_id", "workspace-0"), "input", List.of(Map.of("type", "input_text", "text", "G0_FILES"))); @@ -178,7 +207,7 @@ void publicCreationRunsFilesThroughProductionWorkspaceBinding() throws Exception request("POST", "/v1/agents/sessions", denied, "unsupported", "actor", 409); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_session WHERE tenant_id = ?", Integer.class, tenant)).isEqualTo(2); - assertThat(modelRequests).hasSize(8); + assertThat(modelRequests).hasSize(16); } private void startSpring(Path cli, List roots, int harnessPort, int brokerPort) { @@ -287,9 +316,17 @@ private void modelReply(HttpExchange exchange) throws IOException { List tools = new ArrayList<>(); body.path("tools").forEach(tool -> tools.add(tool.path("function").path("name").asText())); assertThat(tools).containsExactlyInAnyOrder("read_file", "write_file", "edit"); + // Count only this Turn's tool results, after the latest fixture prompt, so a later + // Turn in the same Session runs the same write, edit and read sequence. Other user + // messages the Harness may add do not restart the count. List results = new ArrayList<>(); body.path("messages").forEach(message -> { - if ("tool".equals(message.path("role").asText())) results.add(message); + String role = message.path("role").asText(); + if ("user".equals(role) && message.path("content").toString().contains("G0_")) { + results.clear(); + } else if ("tool".equals(role)) { + results.add(message); + } }); int step = results.size(); if (step == 3) assertThat(results.get(2).toString()).contains("after"); diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java index 2b2fee281df..82b463b4df0 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java @@ -36,6 +36,8 @@ import org.springframework.http.MediaType; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; @SpringBootTest(properties = { "spring.datasource.url=jdbc:h2:mem:workspace-admission;MODE=MySQL;" @@ -65,6 +67,9 @@ class ManagedWorkspaceAdmissionTest { @Autowired private ManagedWorkspaceRegistry registry; + @Autowired + private PlatformTransactionManager transactionManager; + @Test void discoveryFiltersBeforePagingAndKeepsDefaultOutsidePage() throws Exception { @@ -523,6 +528,46 @@ void storeCannotCreateOrDispatchBoundTurnsWhenServiceIsBypassed() { Integer.class, tenant)).isZero(); } + @Test + void enabledStoreAdmitsALaterTurnForTheBoundSessionCreator() { + String tenant = "tenant-" + UUID.randomUUID(); + register(tenant, "ws-a", "storage-a"); + grant(tenant, "ws-a", "actor-a", true); + grant(tenant, "ws-a", "actor-b", true); + String digest = "sha256:" + "a".repeat(64); + String sessionId = store.insertWorkspaceSessionCommand(tenant, + "actor-a", "create", digest, "qwen-code", null, null, + List.of(), null, new WorkspaceSelection("ws-a", ".")) + .sessionId(); + List> input = List.of( + Map.of("type", "text", "text", "again")); + + // The creator lookup gates the service; other readers are not creators. + assertThat(registry.createdSession(tenant, "actor-a", sessionId)) + .isTrue(); + assertThat(registry.createdSession(tenant, "actor-b", sessionId)) + .isFalse(); + assertThat(registry.createdSession("tenant-" + UUID.randomUUID(), + "actor-a", sessionId)).isFalse(); + + ManagedAgentProperties enabled = new ManagedAgentProperties(); + enabled.getHarness().setWorkspaceFilesEnabled(true); + ManagedAgentStore gated = new ManagedAgentStore(jdbc, mapper, + Clock.systemUTC(), ignored -> { + }, registry, enabled); + TransactionTemplate transaction = new TransactionTemplate( + transactionManager); + var admission = transaction.execute(status -> + gated.insertTurnCommand(tenant, "SUBMIT", "later", digest, + sessionId, input, digest)); + assertThat(admission.sessionId()).isEqualTo(sessionId); + assertThat(admission.turnId()).isNotBlank(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM" + + " managed_agent_turn WHERE tenant_id = ?" + + " AND session_id = ?", + Integer.class, tenant, sessionId)).isEqualTo(1); + } + @Test void webShellCreationIsMetadataOnlyUntilExecutionIsWired() throws Exception { diff --git a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts index 04f76952240..c1d5dd5c453 100644 --- a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts +++ b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts @@ -484,7 +484,7 @@ export interface components { * @default false */ workspaceContext: boolean; - /** @description Supports authorized Workspace discovery, Session creation, and saved binding read-back. This capability does not advertise execution readiness. Deployments may separately opt in to an initial Workspace Read/Write/Edit Turn at creation; later submit, cancel and lifecycle operations remain gated. */ + /** @description Supports authorized Workspace discovery, Session creation, and saved binding read-back. This capability does not advertise execution readiness. Deployments may separately opt in to an initial Workspace Read/Write/Edit Turn at creation; the Session creator may submit later Turns under the same opt-in, while cancel and lifecycle operations remain gated. */ workspaceBinding: boolean; }; /** @description Same authorized explicit default as default_workspace, including when outside this page; null if absent or not creatable. A non-null default is active and has canCreateSession=true. */ From 1dc0d0c3307aed0e2e26d1c37ba64c43746f3290 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 30 Sep 2026 22:37:04 +0900 Subject: [PATCH 02/73] feat(web-shell): let the creator send later Turns to a bound Session The server now admits later Turns from a Workspace-bound Session's creator, but the Managed panel still hid the composer for every bound Session and the Java provider forced canSend to false. - WebShellSessionCapabilities gains workspaceTurns, computed per Session and caller with the same rule the submit path enforces (opt-in on, caller can read, caller created the Session). The contract and the generated WebShell types describe it. - The Java provider sends for a bound Session only when the service reports workspaceTurns, and reports it in the summary only when true. - The Managed panel shows the composer for such a Session; cancel stays hidden for bound Sessions. - HostedPublicWorkspaceIT checks the capability per caller through the WebShell route; the contract test's WebShell capability shape and provider/page tests cover the client. --- .../qwen/code/managedagent/api/ApiModels.java | 3 +- .../service/ManagedAgentService.java | 37 +++++++++++-------- .../managed-agent-public-api.openapi.json | 5 +++ .../managedagent/HostedPublicWorkspaceIT.java | 6 +++ .../ManagedAgentApiContractTest.java | 4 +- .../managed/ManagedSessionsPage.test.tsx | 25 +++++++++++++ .../managed/ManagedSessionsPage.tsx | 2 +- .../managed/generated/managed-agent-api.ts | 5 +++ .../java-managed-agent-provider.test.ts | 35 ++++++++++++++++++ .../managed/java-managed-agent-provider.ts | 9 ++++- .../managed/managed-agent-provider.ts | 7 +++- 11 files changed, 116 insertions(+), 22 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ApiModels.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ApiModels.java index a545cdaf2af..aee9f25008b 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ApiModels.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ApiModels.java @@ -73,7 +73,8 @@ public record SessionCapabilities(boolean items, boolean snapshots, boolean tasks) { } - public record WebShellSessionCapabilities(boolean tasks) { + public record WebShellSessionCapabilities(boolean tasks, + boolean workspaceTurns) { } @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java index 7fec012f2a2..a678cfcd815 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java @@ -66,10 +66,6 @@ public class ManagedAgentService { private static final Pattern TURN_CURSOR = Pattern.compile( "^(0|[1-9][0-9]{0,18}):([A-Za-z0-9_-]{1,64})$"); private static final int TURN_ID_MAX_LENGTH = 64; - // Every Session serves its task list and detail; the tasks come from the - // Stage H records its Session store holds (H0c). - private static final WebShellSessionCapabilities WEB_SHELL_CAPABILITIES = - new WebShellSessionCapabilities(true); // Catch-up reads of a stream use pages of this size. static final int STREAM_PAGE = 100; // A context stays ready until cwd changes arrive (W2). @@ -302,7 +298,7 @@ public PublicSession getPublicSession(String tenantId, String actorId, public WebShellSession getWebShellSession(String tenantId, String actorId, String sessionId) { return webShellSession(requireReadableSession(tenantId, actorId, - sessionId)); + sessionId), actorId); } public PublicList listPublicSessions(String tenantId, @@ -327,8 +323,8 @@ public WebShellPage listWebShellSessions( decoded == null ? null : decoded.updatedAt(), decoded == null ? null : decoded.sessionId(), limit); return new WebShellPage<>(page.sessions().stream() - .map(this::webShellSession).toList(), nextCursor(page), - page.hasMore()); + .map(session -> webShellSession(session, actorId)).toList(), + nextCursor(page), page.hasMore()); } /** @@ -483,7 +479,8 @@ private PublicSession publicSession(SessionRecord session) { publicWorkspace(session)); } - private WebShellSession webShellSession(SessionRecord session) { + private WebShellSession webShellSession(SessionRecord session, + String actorId) { TurnRecord latestTurn = store.findLatestTurn(session.tenantId(), session.sessionId()).orElse(null); EventRecord environmentEvent = store.findLatestEnvironmentEvent( @@ -494,7 +491,10 @@ private WebShellSession webShellSession(SessionRecord session) { latestTurn == null ? null : webShellTurn(latestTurn), webShellEnvironment(environmentEvent), session.lastSequence(), webShellWorkspace(session), - WEB_SHELL_CAPABILITIES); + // Every Session serves its task list and detail; the tasks + // come from the Stage H records its Session store holds (H0c). + new WebShellSessionCapabilities(true, + maySubmitWorkspaceTurn(session, actorId))); } private static WebShellWorkspace webShellWorkspace(SessionRecord session) { @@ -643,15 +643,20 @@ String lifecycleDigest(String sessionId, String operation) { // Everyone else keeps the existing refusal. private void requireSubmitter(String tenantId, String actorId, String sessionId) { - SessionRecord session = store.requireSession(tenantId, sessionId); - if (session.workspace() != null + if (!maySubmitWorkspaceTurn(store.requireSession(tenantId, sessionId), + actorId)) { + requireLegacyWorkspace(tenantId, actorId, sessionId); + } + } + + private boolean maySubmitWorkspaceTurn(SessionRecord session, + String actorId) { + return session.workspace() != null && harness.isWorkspaceFilesAvailable() - && workspaces.canRead(tenantId, actorId, + && workspaces.canRead(session.tenantId(), actorId, session.workspace().getWorkspaceId()) - && workspaces.createdSession(tenantId, actorId, sessionId)) { - return; - } - requireLegacyWorkspace(tenantId, actorId, sessionId); + && workspaces.createdSession(session.tenantId(), actorId, + session.sessionId()); } void requireLegacyWorkspace(String tenantId, String actorId, diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json index 9b687a5f0fa..d6f23363bc2 100644 --- a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json +++ b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json @@ -3870,6 +3870,11 @@ "default": false, "x-qwen-implementation-status": "planned" }, + "workspaceTurns": { + "type": "boolean", + "default": false, + "description": "True when the caller may submit later Turns to this Workspace-bound Session: the deployment enables Workspace files and the caller created the Session. False for every other caller and for unbound Sessions, which do not use it." + }, "tasks": { "type": "boolean" } diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java index 03491538384..c1bd5737575 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java @@ -152,6 +152,12 @@ void publicCreationRunsFilesThroughProductionWorkspaceBinding() throws Exception assertThat(request("POST", "/v1/agents/sessions/" + session + "/events", later, "reader-later-" + workspace, "reader", 409).path("error").path("code").asText()) .isEqualTo("workspace_unavailable"); + // WebShell advertises the same rule, per caller. + for (String caller : List.of("actor", "reader")) { + assertThat(request("POST", "/api/agent/web-shell/v1/sessions/get", Map.of("sessionId", session), + null, caller, 200).path("capabilities").path("workspaceTurns").asBoolean()) + .as(caller).isEqualTo("actor".equals(caller)); + } String laterTurn = request("POST", "/v1/agents/sessions/" + session + "/events", later, "later-" + workspace, "actor", 202).path("turn_id").asText(); assertThat(laterTurn).isNotBlank(); diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentApiContractTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentApiContractTest.java index 908b55c0428..0cc57469a2c 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentApiContractTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentApiContractTest.java @@ -1176,8 +1176,8 @@ void bothSurfacesReportTheSameSession() throws Exception { assertThat(other.get("lastSequence").asLong()) .isPositive() .isEqualTo(session.get("last_event_id").asLong()); - assertThat(other.get("capabilities")) - .isEqualTo(json("{\"tasks\":true}")); + assertThat(other.get("capabilities")).isEqualTo(json( + "{\"tasks\":true,\"workspaceTurns\":false}")); } } diff --git a/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx b/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx index 9fc43a52d89..6f1be766759 100644 --- a/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx +++ b/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx @@ -231,6 +231,31 @@ describe('ManagedSessionsPage', () => { expect(container.textContent).not.toContain('Preparing environment'); }); + it('lets the creator send a later Turn to a bound Session', async () => { + mocks.client.getSession.mockResolvedValue( + summary('bound', { + activeTurnId: undefined, + workspace: { workspaceId: 'ws-a', cwdRelative: 'services/api' }, + capabilities: { canSend: true, canCancel: false, workspaceTurns: true }, + }), + ); + mocks.client.submitPrompt.mockResolvedValue({ + sessionId: 'bound', + turnId: 'p2', + }); + await render('bound'); + + expect(container.querySelector('textarea')).not.toBeNull(); + await input('Run it again'); + await click('Send'); + + expect(mocks.client.submitPrompt).toHaveBeenCalledWith( + 'bound', + { text: 'Run it again' }, + expect.objectContaining({ idempotencyKey: expect.any(String) }), + ); + }); + async function click(label: string) { const button = [...container.querySelectorAll('button')].find( (item) => item.textContent === label, diff --git a/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx b/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx index 2a057e3a121..e5c56aba9bf 100644 --- a/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx +++ b/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx @@ -489,7 +489,7 @@ function ManagedSessionsContent({ } loading={detail.loading} /> - {!summary?.workspace && + {(!summary?.workspace || summary.capabilities.workspaceTurns) && (!provider.workspaceBinding || (sessionId && summary)) ? (
{ expect(transcript.olderCursor).toBeUndefined(); expect(transcript.lastEventId).toBe(4); }); + + it('lets a bound Session send only when the service allows its caller', async () => { + const bound = { + sessionId: 'bound-1', + status: 'ACTIVE', + createdAt: 1, + updatedAt: 1, + lastSequence: 3, + workspace: { workspaceId: 'ws-a', cwdRelative: '.' }, + }; + const provider = createJavaManagedAgentProvider({ + baseUrl: 'https://product.example', + fetch: vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + ...bound, + capabilities: { tasks: true, workspaceTurns: true }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + ...bound, + capabilities: { tasks: true, workspaceTurns: false }, + }), + ), + }); + + expect( + (await provider.getSession('bound-1', { clientId: 'c' })).capabilities, + ).toEqual({ canSend: true, canCancel: false, workspaceTurns: true }); + expect( + (await provider.getSession('bound-1', { clientId: 'c' })).capabilities, + ).toEqual({ canSend: false, canCancel: false }); + }); }); diff --git a/packages/web-shell/client/components/managed/java-managed-agent-provider.ts b/packages/web-shell/client/components/managed/java-managed-agent-provider.ts index a7093e609a5..195969c2524 100644 --- a/packages/web-shell/client/components/managed/java-managed-agent-provider.ts +++ b/packages/web-shell/client/components/managed/java-managed-agent-provider.ts @@ -200,6 +200,11 @@ function toSessionSummary( turnStatus, ); const sessionActive = session.status.toLowerCase() === 'active'; + // A bound Session takes later Turns only from the caller the service + // allows; everything else about a bound Session stays read-only. + const workspaceTurns = + Boolean(session.workspace) && + session.capabilities?.workspaceTurns === true; const errorCode = session.activeTurn?.errorCode ?? session.environment?.errorCode; return { @@ -216,12 +221,14 @@ function toSessionSummary( runtimeReady: runtimeState === 'ready', runtimeState, capabilities: { - canSend: sessionActive && !active && !session.workspace, + canSend: + sessionActive && !active && (!session.workspace || workspaceTurns), canCancel: sessionActive && active && turnStatus !== 'cancelling' && !session.workspace, + ...(workspaceTurns ? { workspaceTurns: true } : {}), }, ...(errorCode ? { failure: { code: errorCode, message: errorCode } } : {}), }; diff --git a/packages/web-shell/client/components/managed/managed-agent-provider.ts b/packages/web-shell/client/components/managed/managed-agent-provider.ts index a05476cf71d..227e77f355f 100644 --- a/packages/web-shell/client/components/managed/managed-agent-provider.ts +++ b/packages/web-shell/client/components/managed/managed-agent-provider.ts @@ -28,7 +28,12 @@ export interface ManagedAgentSessionSummary { phase: ManagedAgentSessionPhase; runtimeReady: boolean; runtimeState: ManagedAgentRuntimeState; - capabilities: { canSend: boolean; canCancel: boolean }; + capabilities: { + canSend: boolean; + canCancel: boolean; + /** The caller may submit later Turns to this Workspace-bound Session. */ + workspaceTurns?: boolean; + }; failure?: { code: string; message: string }; } From aedd7a66665c0925c1aec3364fdbc5082e3f049d Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 30 Sep 2026 22:44:32 +0900 Subject: [PATCH 03/73] feat(managed-agent): let the bound Session creator cancel a running Turn Later Turns of a Workspace-bound Session could be submitted by the creator but not cancelled: the service and store refused the cancel, and the coordinator skipped bound Sessions entirely. Apply the same rule to cancellation. The creator, with the Workspace- files opt-in on, may cancel; the coordinator then cancels through the Hosted Harness like any other Turn, which aborts the Turn and settles its Runtime calls through their original identities. Other actors and deployments without the opt-in keep the existing refusal, and the coordinator still leaves a bound Session alone without the opt-in. - HarnessCoordinatorTest: without the opt-in only the opt-in is read; with it, the bound Turn is cancelled through the Harness. - HostedPublicWorkspaceIT: a later Turn is held in the model call; a reader's cancel is refused, the creator's cancel is accepted, and the Turn ends CANCELLED with no tool execution. - WebShell reports canCancel for such a Session when workspaceTurns is set, so the Managed panel shows Cancel. - Contract text, generated types, README and both G0 design documents now keep only lifecycle operations gated. --- ...09-29-hosted-public-workspace-admission.md | 11 ++-- ...hosted-public-workspace-admission.zh-CN.md | 2 +- .../sdk-java/managed-agent-server/README.md | 8 +-- .../service/HarnessCoordinator.java | 7 ++- .../service/ManagedAgentService.java | 6 +-- .../managedagent/store/ManagedAgentStore.java | 3 +- .../managed-agent-public-api.openapi.json | 6 +-- .../managedagent/HostedPublicWorkspaceIT.java | 37 +++++++++++++- .../service/HarnessCoordinatorTest.java | 50 +++++++++++++++---- .../managed/generated/managed-agent-api.ts | 4 +- .../java-managed-agent-provider.test.ts | 26 ++++++++++ .../managed/java-managed-agent-provider.ts | 2 +- 12 files changed, 128 insertions(+), 34 deletions(-) diff --git a/docs/design/2026-09-29-hosted-public-workspace-admission.md b/docs/design/2026-09-29-hosted-public-workspace-admission.md index bb8551c2b3e..dfafe894469 100644 --- a/docs/design/2026-09-29-hosted-public-workspace-admission.md +++ b/docs/design/2026-09-29-hosted-public-workspace-admission.md @@ -18,11 +18,12 @@ service. Later submit, cancel, rename, lifecycle and cwd operations retain their existing Workspace gates. Discovery continues to advertise only Workspace binding, not complete Workspace execution support. No UI changes are required. -A follow-up admits later Turns for the Session's creator under the same opt-in. -Execution authorizes every Turn against the creator's Workspace grants, so any -other actor, and every deployment without the opt-in, keeps the existing -`workspace_unavailable` refusal. Cancel, rename, lifecycle and cwd operations -remain gated. +A follow-up admits later Turns for the Session's creator under the same opt-in, +and lets the creator cancel a running Turn, which the Hosted Harness aborts and +settles through the original Runtime identities. Execution authorizes every +Turn against the creator's Workspace grants, so any other actor, and every +deployment without the opt-in, keeps the existing `workspace_unavailable` +refusal. Rename, lifecycle and cwd operations remain gated. ## Decisions diff --git a/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md b/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md index 85fbf0f3cf0..7f0bd269f2a 100644 --- a/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md +++ b/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md @@ -10,7 +10,7 @@ Hosted Read/Write/Edit 已能经过生产 Broker 和 worker 执行,但目前 G0 开放随创建会话准入的一次初始文件工具轮次,使用现有公开 REST 路由及共享 service 的 WebShell 创建适配器。后续提交、取消、重命名、生命周期与 cwd 操作保持现有 Workspace 门禁。发现接口仍仅广播 Workspace 绑定能力,不宣称完整的 Workspace 执行支持。无需修改 UI。 -后续改动在同一开关下为会话创建者开放后续 Turn。执行时每个 Turn 都按创建者的 Workspace 授权校验,因此其他 actor 以及未开启该开关的部署仍得到现有的 `workspace_unavailable` 拒绝。取消、重命名、生命周期与 cwd 操作仍保持门禁。 +后续改动在同一开关下为会话创建者开放后续 Turn,并允许创建者取消正在运行的 Turn,由 Hosted Harness 中止该 Turn 并按原始 Runtime 身份结算。执行时每个 Turn 都按创建者的 Workspace 授权校验,因此其他 actor 以及未开启该开关的部署仍得到现有的 `workspace_unavailable` 拒绝。重命名、生命周期与 cwd 操作仍保持门禁。 ## 决策 diff --git a/packages/sdk-java/managed-agent-server/README.md b/packages/sdk-java/managed-agent-server/README.md index a644a323b14..951b0981cb4 100644 --- a/packages/sdk-java/managed-agent-server/README.md +++ b/packages/sdk-java/managed-agent-server/README.md @@ -426,10 +426,10 @@ local workloads. The opt-in W0e recovery above handles trusted host reboot; it does not provide physical isolation or recovery after worker-only death. Public bound Turn admission is limited to the opt-in initial file Turn described in G0 above and to later Turns submitted by the Session's creator under the same -opt-in. Later Turns run under the creator's Workspace grants, so any other actor -keeps the `workspace_unavailable` refusal. Public cancel and lifecycle -operations remain gated; the private Shell profile is not enabled through -public creation. +opt-in; the creator may also cancel them. Later Turns run under the creator's +Workspace grants, so any other actor keeps the `workspace_unavailable` refusal. +Public lifecycle operations remain gated; the private Shell profile is not +enabled through public creation. See the bilingual [execution design](../../../docs/design/2026-09-26-managed-workspace-execution.md) for the exact boundary. diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java index fcd43ee2c15..540a317441c 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java @@ -542,7 +542,12 @@ private void cancelAdmittedTurn(String tenantId, String sessionId, } SessionRecord session = store.requireSession(tenantId, sessionId); - if (session.workspace() != null) { + // A bound Session's Turn is cancelled like any other once + // Workspace files are enabled: the Hosted Harness aborts the + // Turn and settles its Runtime calls through their original + // identities. Without the opt-in nothing may reach it. + if (session.workspace() != null + && !harness.isWorkspaceFilesAvailable()) { return; } Attachment attachment = harness.createOrLoad( diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java index a678cfcd815..67da69a77ae 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java @@ -204,7 +204,7 @@ public CommandAdmission submitTurn(String tenantId, String actorId, public CommandAdmission cancelTurn(String tenantId, String actorId, String idempotencyKey, String sessionId, String turnId) { validateIdempotencyKey(idempotencyKey); - requireLegacyWorkspace(tenantId, actorId, sessionId); + requireSubmitter(tenantId, actorId, sessionId); String requestDigest = digests.digest(Map.of( "sessionId", sessionId, "turnId", turnId)); Admission replay = replay(tenantId, CANCEL, idempotencyKey, @@ -639,8 +639,8 @@ String lifecycleDigest(String sessionId, String operation) { // Later Turns of a Workspace-bound Session run under the creator's // Workspace grants (WorkspaceExecutionStore.authorize), so only the - // creator may submit them, and only with Workspace files enabled. - // Everyone else keeps the existing refusal. + // creator may submit or cancel them, and only with Workspace files + // enabled. Everyone else keeps the existing refusal. private void requireSubmitter(String tenantId, String actorId, String sessionId) { if (!maySubmitWorkspaceTurn(store.requireSession(tenantId, sessionId), diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java index 5aa9e7609ac..0b77e1f2745 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java @@ -444,7 +444,8 @@ public Admission insertTurnCommand(String tenantId, String operation, public Admission insertCancelCommand(String tenantId, String operation, String idempotencyKey, String requestDigest, String sessionId, String turnId) { - if (requireSessionForUpdate(tenantId, sessionId).workspace() != null) { + if (requireSessionForUpdate(tenantId, sessionId).workspace() != null + && !workspaceFilesEnabled) { throw workspaceExecutionUnavailable(); } TurnRecord turn = requireTurn(tenantId, sessionId, turnId); diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json index d6f23363bc2..614b8924025 100644 --- a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json +++ b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json @@ -3873,7 +3873,7 @@ "workspaceTurns": { "type": "boolean", "default": false, - "description": "True when the caller may submit later Turns to this Workspace-bound Session: the deployment enables Workspace files and the caller created the Session. False for every other caller and for unbound Sessions, which do not use it." + "description": "True when the caller may submit and cancel later Turns of this Workspace-bound Session: the deployment enables Workspace files and the caller created the Session. False for every other caller and for unbound Sessions, which do not use it." }, "tasks": { "type": "boolean" @@ -4285,7 +4285,7 @@ }, "workspace_binding": { "type": "boolean", - "description": "Supports authorized Workspace discovery, Session creation, and saved binding read-back. This capability does not advertise execution readiness. Deployments may separately opt in to an initial Workspace Read/Write/Edit Turn at creation; the Session creator may submit later Turns under the same opt-in, while cancel and lifecycle operations remain gated." + "description": "Supports authorized Workspace discovery, Session creation, and saved binding read-back. This capability does not advertise execution readiness. Deployments may separately opt in to an initial Workspace Read/Write/Edit Turn at creation; the Session creator may submit and cancel later Turns under the same opt-in, while lifecycle operations remain gated." } }, "x-qwen-implementation-status": "partial" @@ -4518,7 +4518,7 @@ }, "workspaceBinding": { "type": "boolean", - "description": "Supports authorized Workspace discovery, Session creation, and saved binding read-back. This capability does not advertise execution readiness. Deployments may separately opt in to an initial Workspace Read/Write/Edit Turn at creation; the Session creator may submit later Turns under the same opt-in, while cancel and lifecycle operations remain gated." + "description": "Supports authorized Workspace discovery, Session creation, and saved binding read-back. This capability does not advertise execution readiness. Deployments may separately opt in to an initial Workspace Read/Write/Edit Turn at creation; the Session creator may submit and cancel later Turns under the same opt-in, while lifecycle operations remain gated." } }, "x-qwen-implementation-status": "partial" diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java index c1bd5737575..a18e2afc673 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java @@ -29,6 +29,7 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.AfterEach; @@ -50,6 +51,8 @@ class HostedPublicWorkspaceIT { private final String tenant = "g0-" + UUID.randomUUID(); private final List modelRequests = new CopyOnWriteArrayList<>(); private final AtomicReference modelFailure = new AtomicReference<>(); + // Holds the model reply to a G0_CANCEL prompt so the test can cancel a running Turn. + private volatile CountDownLatch heldReply = new CountDownLatch(1); @TempDir(cleanup = CleanupMode.ON_SUCCESS) private Path temporary; private ServletWebServerApplicationContext spring; @@ -179,8 +182,30 @@ void publicCreationRunsFilesThroughProductionWorkspaceBinding() throws Exception assertThat(modelRequests).hasSize(requests + 4); assertThat(Files.readString(roots.get(index).resolve("child/proof.txt"))).isEqualTo("after"); assertThat(decoy.resolve("proof.txt")).doesNotExist(); + + // The creator can cancel a running later Turn; the Hosted Harness aborts it before + // any tool runs. Another reader keeps the refusal. + int beforeCancel = modelRequests.size(); + Map hold = Map.of("type", "agent.session.input.message", "input", + List.of(Map.of("type", "input_text", "text", "G0_CANCEL"))); + String heldTurn = request("POST", "/v1/agents/sessions/" + session + "/events", hold, + "hold-" + workspace, "actor", 202).path("turn_id").asText(); + await().atMost(Duration.ofSeconds(35)).until(() -> modelRequests.size() > beforeCancel); + Map cancel = Map.of("type", "agent.session.cancel", "turn_id", heldTurn); + assertThat(request("POST", "/v1/agents/sessions/" + session + "/events", cancel, + "reader-cancel-" + workspace, "reader", 409).path("error").path("code").asText()) + .isEqualTo("workspace_unavailable"); + request("POST", "/v1/agents/sessions/" + session + "/events", cancel, "cancel-" + workspace, + "actor", 202); + await().atMost(Duration.ofSeconds(35)).untilAsserted(() -> assertThat(jdbc.queryForObject( + "SELECT status FROM managed_agent_turn WHERE session_id = ? AND turn_id = ?", + String.class, session, heldTurn)).isEqualTo("CANCELLED")); + heldReply.countDown(); + heldReply = new CountDownLatch(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM qwen_tool_execution WHERE harness_session_id = ?", + Long.class, session)).isEqualTo(executions * 2); } - assertThat(modelRequests).hasSize(16); + assertThat(modelRequests).hasSize(18); assertThat(modelFailure.get()).isNull(); Map denied = Map.of("agent_id", "qwen-code", "workspace", Map.of("workspace_id", "workspace-0"), "input", List.of(Map.of("type", "input_text", "text", "G0_FILES"))); @@ -213,7 +238,7 @@ void publicCreationRunsFilesThroughProductionWorkspaceBinding() throws Exception request("POST", "/v1/agents/sessions", denied, "unsupported", "actor", 409); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_session WHERE tenant_id = ?", Integer.class, tenant)).isEqualTo(2); - assertThat(modelRequests).hasSize(16); + assertThat(modelRequests).hasSize(18); } private void startSpring(Path cli, List roots, int harnessPort, int brokerPort) { @@ -326,14 +351,22 @@ private void modelReply(HttpExchange exchange) throws IOException { // Turn in the same Session runs the same write, edit and read sequence. Other user // messages the Harness may add do not restart the count. List results = new ArrayList<>(); + AtomicReference prompt = new AtomicReference<>(""); body.path("messages").forEach(message -> { String role = message.path("role").asText(); if ("user".equals(role) && message.path("content").toString().contains("G0_")) { results.clear(); + prompt.set(message.path("content").toString()); } else if ("tool".equals(role)) { results.add(message); } }); + if (prompt.get().contains("G0_CANCEL")) { + // Reply with nothing until the test has cancelled the Turn; the Harness has + // aborted this request by then, so there is no response to write. + heldReply.await(60, TimeUnit.SECONDS); + return; + } int step = results.size(); if (step == 3) assertThat(results.get(2).toString()).contains("after"); var chunk = json.createObjectNode().put("id", "g0").put("object", "chat.completion.chunk") diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java index 7f1f2e44b30..71e2172417a 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java @@ -137,30 +137,58 @@ void classifiesWorkspaceRefusalBeforeSubmission(boolean retryable, } @Test - void boundCancellationNeverCallsTheLegacyHarness() { - AgentStateStore store = mock(AgentStateStore.class); + void boundCancellationWaitsForTheWorkspaceOptIn() { + AgentStateStore store = boundCancellingStore(); HarnessConnector harness = mock(HarnessConnector.class); RuntimeWarmer warmer = mock(RuntimeWarmer.class); - when(store.findTurn("tenant", "session", "turn")).thenReturn(Optional.of( - turn("tenant", "session", "turn", "prompt", "epoch", 1, - "CANCELLING"))); - when(store.requireSession("tenant", "session")).thenReturn( - new SessionRecord("tenant", "session", "qwen-code", null, - null, "ACTIVE", "boot", "epoch", 1, 1, 0, 1, 1, null, 1, - new ContextBinding("tenant", "ws-a", 1, - "storage-a", ".", "config-a", 1))); HarnessCoordinator coordinator = new HarnessCoordinator(store, harness, new HarnessEventProjector(), warmer, directExecutor(), Clock.systemUTC(), new ManagedAgentProperties()); try { coordinator.cancel("tenant", "session", "turn"); verify(store).requireSession("tenant", "session"); - verifyNoInteractions(harness, warmer); + verify(harness).isWorkspaceFilesAvailable(); + verifyNoMoreInteractions(harness); + verifyNoInteractions(warmer); + } finally { + coordinator.close(); + } + } + + @Test + void cancelsABoundTurnThroughTheHarnessWithTheWorkspaceOptIn() { + AgentStateStore store = boundCancellingStore(); + HarnessConnector harness = mock(HarnessConnector.class); + when(harness.isWorkspaceFilesAvailable()).thenReturn(true); + when(harness.createOrLoad("tenant", "session", true)) + .thenReturn(new Attachment("boot", null, null, null)); + when(store.bindHarness(eq("tenant"), eq("session"), eq("turn"), + anyString(), eq("boot"))).thenReturn(true); + HarnessCoordinator coordinator = new HarnessCoordinator(store, harness, + new HarnessEventProjector(), mock(RuntimeWarmer.class), + directExecutor(), Clock.systemUTC(), + new ManagedAgentProperties()); + try { + coordinator.cancel("tenant", "session", "turn"); + verify(harness).cancel("tenant", "session"); } finally { coordinator.close(); } } + private static AgentStateStore boundCancellingStore() { + AgentStateStore store = mock(AgentStateStore.class); + when(store.findTurn("tenant", "session", "turn")).thenReturn(Optional.of( + turn("tenant", "session", "turn", "prompt", "epoch", 1, + "CANCELLING"))); + when(store.requireSession("tenant", "session")).thenReturn( + new SessionRecord("tenant", "session", "qwen-code", null, + null, "ACTIVE", "boot", "epoch", 1, 1, 0, 1, 1, null, 1, + new ContextBinding("tenant", "ws-a", 1, + "storage-a", ".", "config-a", 1))); + return store; + } + @Test void cancelsKnownSettledRecoveredRuntimeAndStreamsCancellation() { String tenantId = "tenant-recovery-cancel"; diff --git a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts index 69042a0173a..b4c264f34df 100644 --- a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts +++ b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts @@ -355,7 +355,7 @@ export interface components { }; WebShellSessionCapabilities: { /** - * @description True when the caller may submit later Turns to this Workspace-bound Session: the deployment enables Workspace files and the caller created the Session. False for every other caller and for unbound Sessions, which do not use it. + * @description True when the caller may submit and cancel later Turns of this Workspace-bound Session: the deployment enables Workspace files and the caller created the Session. False for every other caller and for unbound Sessions, which do not use it. * @default false */ workspaceTurns: boolean; @@ -489,7 +489,7 @@ export interface components { * @default false */ workspaceContext: boolean; - /** @description Supports authorized Workspace discovery, Session creation, and saved binding read-back. This capability does not advertise execution readiness. Deployments may separately opt in to an initial Workspace Read/Write/Edit Turn at creation; the Session creator may submit later Turns under the same opt-in, while cancel and lifecycle operations remain gated. */ + /** @description Supports authorized Workspace discovery, Session creation, and saved binding read-back. This capability does not advertise execution readiness. Deployments may separately opt in to an initial Workspace Read/Write/Edit Turn at creation; the Session creator may submit and cancel later Turns under the same opt-in, while lifecycle operations remain gated. */ workspaceBinding: boolean; }; /** @description Same authorized explicit default as default_workspace, including when outside this page; null if absent or not creatable. A non-null default is active and has canCreateSession=true. */ diff --git a/packages/web-shell/client/components/managed/java-managed-agent-provider.test.ts b/packages/web-shell/client/components/managed/java-managed-agent-provider.test.ts index 1eec373007a..7189285853c 100644 --- a/packages/web-shell/client/components/managed/java-managed-agent-provider.test.ts +++ b/packages/web-shell/client/components/managed/java-managed-agent-provider.test.ts @@ -480,4 +480,30 @@ describe('createJavaManagedAgentProvider', () => { (await provider.getSession('bound-1', { clientId: 'c' })).capabilities, ).toEqual({ canSend: false, canCancel: false }); }); + + it('lets the allowed caller cancel a running Turn of a bound Session', async () => { + const provider = createJavaManagedAgentProvider({ + baseUrl: 'https://product.example', + fetch: vi.fn().mockResolvedValue( + jsonResponse({ + sessionId: 'bound-1', + status: 'ACTIVE', + createdAt: 1, + updatedAt: 2, + lastSequence: 5, + workspace: { workspaceId: 'ws-a', cwdRelative: '.' }, + activeTurn: { + turnId: 'turn-2', + sessionId: 'bound-1', + status: 'RUNNING', + submittedAt: 2, + }, + capabilities: { tasks: true, workspaceTurns: true }, + }), + ), + }); + expect( + (await provider.getSession('bound-1', { clientId: 'c' })).capabilities, + ).toEqual({ canSend: false, canCancel: true, workspaceTurns: true }); + }); }); diff --git a/packages/web-shell/client/components/managed/java-managed-agent-provider.ts b/packages/web-shell/client/components/managed/java-managed-agent-provider.ts index 195969c2524..7192500f6c3 100644 --- a/packages/web-shell/client/components/managed/java-managed-agent-provider.ts +++ b/packages/web-shell/client/components/managed/java-managed-agent-provider.ts @@ -227,7 +227,7 @@ function toSessionSummary( sessionActive && active && turnStatus !== 'cancelling' && - !session.workspace, + (!session.workspace || workspaceTurns), ...(workspaceTurns ? { workspaceTurns: true } : {}), }, ...(errorCode ? { failure: { code: errorCode, message: errorCode } } : {}), From e12dc742b0205b2d8a240b0711f8fccb4e90a752 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 30 Sep 2026 22:47:17 +0900 Subject: [PATCH 04/73] feat(managed-agent): let the bound Session creator rename it Rename of a Workspace-bound Session answered 409 until a role source decided who may change it (D4 4.9). #12867 settled the first version: the Session's creator is its only owner. Rename changes only the title through the Hosted Harness and touches no Runtime, so apply the same creator rule under the Workspace-files opt-in; the store admits only a RENAME mutation of a bound Session, and only under the opt-in. Close, archive, delete and unarchive stay gated: the embedded Broker's drain only stops warming a closed Session and has no Harness-level teardown yet, so closing a bound Session would leave its worker and any held Workspace lease behind. HostedPublicWorkspaceIT: a reader's rename is refused; the creator's rename returns the new title. --- .../2026-09-29-hosted-public-workspace-admission.md | 4 +++- ...26-09-29-hosted-public-workspace-admission.zh-CN.md | 2 +- packages/sdk-java/managed-agent-server/README.md | 8 ++++---- .../code/managedagent/service/ManagedAgentService.java | 6 +++--- .../code/managedagent/store/ManagedAgentStore.java | 10 ++++++++-- .../code/managedagent/HostedPublicWorkspaceIT.java | 7 +++++++ 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/docs/design/2026-09-29-hosted-public-workspace-admission.md b/docs/design/2026-09-29-hosted-public-workspace-admission.md index dfafe894469..838a51df207 100644 --- a/docs/design/2026-09-29-hosted-public-workspace-admission.md +++ b/docs/design/2026-09-29-hosted-public-workspace-admission.md @@ -23,7 +23,9 @@ and lets the creator cancel a running Turn, which the Hosted Harness aborts and settles through the original Runtime identities. Execution authorizes every Turn against the creator's Workspace grants, so any other actor, and every deployment without the opt-in, keeps the existing `workspace_unavailable` -refusal. Rename, lifecycle and cwd operations remain gated. +refusal. The creator may also rename the Session. Close, archive, delete, +unarchive and cwd operations remain gated: the Runtime Broker's drain only +stops warming a closed Session and has no Harness-level teardown yet. ## Decisions diff --git a/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md b/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md index 7f0bd269f2a..f61c1b365d0 100644 --- a/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md +++ b/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md @@ -10,7 +10,7 @@ Hosted Read/Write/Edit 已能经过生产 Broker 和 worker 执行,但目前 G0 开放随创建会话准入的一次初始文件工具轮次,使用现有公开 REST 路由及共享 service 的 WebShell 创建适配器。后续提交、取消、重命名、生命周期与 cwd 操作保持现有 Workspace 门禁。发现接口仍仅广播 Workspace 绑定能力,不宣称完整的 Workspace 执行支持。无需修改 UI。 -后续改动在同一开关下为会话创建者开放后续 Turn,并允许创建者取消正在运行的 Turn,由 Hosted Harness 中止该 Turn 并按原始 Runtime 身份结算。执行时每个 Turn 都按创建者的 Workspace 授权校验,因此其他 actor 以及未开启该开关的部署仍得到现有的 `workspace_unavailable` 拒绝。重命名、生命周期与 cwd 操作仍保持门禁。 +后续改动在同一开关下为会话创建者开放后续 Turn,并允许创建者取消正在运行的 Turn,由 Hosted Harness 中止该 Turn 并按原始 Runtime 身份结算。执行时每个 Turn 都按创建者的 Workspace 授权校验,因此其他 actor 以及未开启该开关的部署仍得到现有的 `workspace_unavailable` 拒绝。创建者也可以重命名该会话。关闭、归档、删除、取消归档与 cwd 操作仍保持门禁:Runtime Broker 的 drain 只是停止为已关闭会话预热,尚无 Harness 级别的回收。 ## 决策 diff --git a/packages/sdk-java/managed-agent-server/README.md b/packages/sdk-java/managed-agent-server/README.md index 951b0981cb4..be2bdabb47a 100644 --- a/packages/sdk-java/managed-agent-server/README.md +++ b/packages/sdk-java/managed-agent-server/README.md @@ -426,10 +426,10 @@ local workloads. The opt-in W0e recovery above handles trusted host reboot; it does not provide physical isolation or recovery after worker-only death. Public bound Turn admission is limited to the opt-in initial file Turn described in G0 above and to later Turns submitted by the Session's creator under the same -opt-in; the creator may also cancel them. Later Turns run under the creator's -Workspace grants, so any other actor keeps the `workspace_unavailable` refusal. -Public lifecycle operations remain gated; the private Shell profile is not -enabled through public creation. +opt-in; the creator may also cancel them and rename the Session. Later Turns run +under the creator's Workspace grants, so any other actor keeps the +`workspace_unavailable` refusal. Public close, archive, delete and unarchive +remain gated; the private Shell profile is not enabled through public creation. See the bilingual [execution design](../../../docs/design/2026-09-26-managed-workspace-execution.md) for the exact boundary. diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java index 67da69a77ae..140d26e44ca 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java @@ -234,7 +234,7 @@ public SessionMutationResult renameSession( String tenantId, String actorId, String idempotencyKey, String sessionId, String title) { validateIdempotencyKey(idempotencyKey); - requireLegacyWorkspace(tenantId, actorId, sessionId); + requireSubmitter(tenantId, actorId, sessionId); String effectiveTitle = validRenameTitle(title); String requestDigest = digests.digest(Map.of( "sessionId", sessionId, "title", effectiveTitle)); @@ -639,8 +639,8 @@ String lifecycleDigest(String sessionId, String operation) { // Later Turns of a Workspace-bound Session run under the creator's // Workspace grants (WorkspaceExecutionStore.authorize), so only the - // creator may submit or cancel them, and only with Workspace files - // enabled. Everyone else keeps the existing refusal. + // creator may submit or cancel them or rename the Session, and only with + // Workspace files enabled. Everyone else keeps the existing refusal. private void requireSubmitter(String tenantId, String actorId, String sessionId) { if (!maySubmitWorkspaceTurn(store.requireSession(tenantId, sessionId), diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java index 0b77e1f2745..fff3e3b9733 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java @@ -312,6 +312,12 @@ private record WorkspaceCommand(String requestDigest, String sessionId, String turnId) { } + // A bound Session's creator may rename it under the Workspace-files + // opt-in (the service checks the creator); unarchive stays gated. + private boolean boundRenameAllowed(SessionMutationKind kind) { + return kind == SessionMutationKind.RENAME && workspaceFilesEnabled; + } + private static ApiException workspaceExecutionUnavailable() { return new ApiException(HttpStatus.CONFLICT, "workspace_unavailable", @@ -476,7 +482,7 @@ public SessionMutationCommand beginSessionMutation(String tenantId, String operation, String idempotencyKey, String requestDigest, String sessionId, SessionMutationKind kind) { SessionRecord session = requireSessionForUpdate(tenantId, sessionId); - if (session.workspace() != null) { + if (session.workspace() != null && !boundRenameAllowed(kind)) { throw workspaceExecutionUnavailable(); } Optional existing = findCommand(tenantId, operation, @@ -509,7 +515,7 @@ public SessionRecord completeSessionMutation(String tenantId, String operation, String idempotencyKey, String sessionId, SessionMutationKind kind, String title, String harnessBootId) { SessionRecord session = requireSessionForUpdate(tenantId, sessionId); - if (session.workspace() != null) { + if (session.workspace() != null && !boundRenameAllowed(kind)) { throw workspaceExecutionUnavailable(); } CommandRecord command = findCommand(tenantId, operation, diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java index a18e2afc673..88277270b37 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java @@ -204,6 +204,13 @@ void publicCreationRunsFilesThroughProductionWorkspaceBinding() throws Exception heldReply = new CountDownLatch(1); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM qwen_tool_execution WHERE harness_session_id = ?", Long.class, session)).isEqualTo(executions * 2); + + // Only the creator may rename the bound Session. + Map rename = Map.of("title", "Renamed " + workspace); + assertThat(request("PATCH", "/v1/agents/sessions/" + session, rename, "reader-rename-" + workspace, + "reader", 409).path("error").path("code").asText()).isEqualTo("workspace_unavailable"); + assertThat(request("PATCH", "/v1/agents/sessions/" + session, rename, "rename-" + workspace, + "actor", 200).path("title").asText()).isEqualTo("Renamed " + workspace); } assertThat(modelRequests).hasSize(18); assertThat(modelFailure.get()).isNull(); From 3df9ee1215c7510f1db0cc50bba80d30b89cf276 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 30 Sep 2026 22:58:01 +0900 Subject: [PATCH 05/73] style(web-shell): keep the workspaceTurns expression on one line as Prettier prints it --- .../client/components/managed/java-managed-agent-provider.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/web-shell/client/components/managed/java-managed-agent-provider.ts b/packages/web-shell/client/components/managed/java-managed-agent-provider.ts index 7192500f6c3..13d72d71e7c 100644 --- a/packages/web-shell/client/components/managed/java-managed-agent-provider.ts +++ b/packages/web-shell/client/components/managed/java-managed-agent-provider.ts @@ -203,8 +203,7 @@ function toSessionSummary( // A bound Session takes later Turns only from the caller the service // allows; everything else about a bound Session stays read-only. const workspaceTurns = - Boolean(session.workspace) && - session.capabilities?.workspaceTurns === true; + Boolean(session.workspace) && session.capabilities?.workspaceTurns === true; const errorCode = session.activeTurn?.errorCode ?? session.environment?.errorCode; return { From a55054032014fa1cecbeeb67b52fd241f510b515 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 30 Sep 2026 23:21:08 +0900 Subject: [PATCH 06/73] test(managed-agent): read the renamed title from the public Session metadata PublicSession carries its title under metadata.title; the rename assertion read a top-level title and saw an empty string although the rename returned 200. --- .../alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java index 88277270b37..6223efef6c6 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java @@ -210,7 +210,7 @@ void publicCreationRunsFilesThroughProductionWorkspaceBinding() throws Exception assertThat(request("PATCH", "/v1/agents/sessions/" + session, rename, "reader-rename-" + workspace, "reader", 409).path("error").path("code").asText()).isEqualTo("workspace_unavailable"); assertThat(request("PATCH", "/v1/agents/sessions/" + session, rename, "rename-" + workspace, - "actor", 200).path("title").asText()).isEqualTo("Renamed " + workspace); + "actor", 200).path("metadata").path("title").asText()).isEqualTo("Renamed " + workspace); } assertThat(modelRequests).hasSize(18); assertThat(modelFailure.get()).isNull(); From b8c5830ad8e63695473bc6017a492db946f82f69 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 30 Sep 2026 23:47:47 +0900 Subject: [PATCH 07/73] fix(web-shell): match the generated optional workspaceTurns capability --- .../client/components/managed/generated/managed-agent-api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts index b4c264f34df..2a944e3128e 100644 --- a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts +++ b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts @@ -358,7 +358,7 @@ export interface components { * @description True when the caller may submit and cancel later Turns of this Workspace-bound Session: the deployment enables Workspace files and the caller created the Session. False for every other caller and for unbound Sessions, which do not use it. * @default false */ - workspaceTurns: boolean; + workspaceTurns?: boolean; tasks: boolean; }; WebShellSessionPage: { From f2a028b87615ea445b16f80cece8717bfec4d7d0 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 1 Oct 2026 02:30:52 +0900 Subject: [PATCH 08/73] test(managed-agent): keep the approval run out of the later-Turn checks --- .../qwen/code/managedagent/HostedPublicWorkspaceIT.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java index c2930a8c274..1598358a6c8 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java @@ -160,6 +160,9 @@ private void runFiles() throws Exception { assertThat(request("POST", route, changed, workspace, "actor", 409).path("error").path("code").asText()) .isEqualTo("idempotency_conflict"); request("GET", "/v1/agents/sessions/" + session, null, null, "other", 404); + // The approval run registers its own reader and answers only the initial Turn; + // later Turns are covered by the files run. + if (approvals) continue; // A later Turn runs under the creator's grants: another actor who can read the // Session keeps the refusal, and the creator's second Turn runs the file tools again. jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read," @@ -227,7 +230,7 @@ private void runFiles() throws Exception { assertThat(request("PATCH", "/v1/agents/sessions/" + session, rename, "rename-" + workspace, "actor", 200).path("metadata").path("title").asText()).isEqualTo("Renamed " + workspace); } - assertThat(modelRequests).hasSize(18); + assertThat(modelRequests).hasSize(approvals ? 8 : 18); assertThat(modelFailure.get()).isNull(); Map denied = Map.of("agent_id", "qwen-code", "workspace", Map.of("workspace_id", "workspace-0"), "input", List.of(Map.of("type", "input_text", "text", "G0_FILES"))); @@ -260,7 +263,7 @@ private void runFiles() throws Exception { request("POST", "/v1/agents/sessions", denied, "unsupported", "actor", 409); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_session WHERE tenant_id = ?", Integer.class, tenant)).isEqualTo(2); - assertThat(modelRequests).hasSize(18); + assertThat(modelRequests).hasSize(approvals ? 8 : 18); } private void startSpring(Path cli, List roots, int harnessPort, int brokerPort) { From e96c116a6972c4d61275cb271f9db6f0647448c6 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 1 Oct 2026 06:55:46 +0800 Subject: [PATCH 09/73] docs(managed-agent): Align G0 gating statements with creator later-Turn admission The G0 section and Prerequisites still published the pre-follow-up gate ("later submit/cancel remain gated"), contradicting the boundary paragraph this PR adds; state the creator's submit/cancel/rename admission and keep close/archive/delete/unarchive and cwd gated. Scope the follow-up's refusal precisely: requireLegacyWorkspace answers workspace_unavailable only when the actor can read the Workspace, and session_not_found otherwise. Apply the same two corrections to the bilingual design doc and note the follow-up's composer UI change. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmuoob5v90j --- ...-09-29-hosted-public-workspace-admission.md | 18 +++++++++++------- ...-hosted-public-workspace-admission.zh-CN.md | 4 ++-- .../sdk-java/managed-agent-server/README.md | 18 +++++++++++------- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/docs/design/2026-09-29-hosted-public-workspace-admission.md b/docs/design/2026-09-29-hosted-public-workspace-admission.md index 8ce0ea2b89a..1fc2cf8eaa9 100644 --- a/docs/design/2026-09-29-hosted-public-workspace-admission.md +++ b/docs/design/2026-09-29-hosted-public-workspace-admission.md @@ -14,18 +14,22 @@ and uses the deployment's global Workspace for every Session Store connection. G0 enables one initial file-tool Turn admitted with Session creation. It uses the existing public REST route and the WebShell creation adapter that shares its -service. Later submit, cancel, rename, lifecycle and cwd operations retain their -existing Workspace gates. Discovery continues to advertise only Workspace -binding, not complete Workspace execution support. No UI changes are required. +service. The follow-up below admits later Turns for the Session's creator under +the same opt-in, including cancel and rename; lifecycle and cwd operations +retain their existing Workspace gates. Discovery continues to advertise only +Workspace binding, not complete Workspace execution support. G0 requires no UI +changes; the follow-up's only UI change is enabling the creator's composer. A follow-up admits later Turns for the Session's creator under the same opt-in, and lets the creator cancel a running Turn, which the Hosted Harness aborts and settles through the original Runtime identities. Execution authorizes every Turn against the creator's Workspace grants, so any other actor, and every -deployment without the opt-in, keeps the existing `workspace_unavailable` -refusal. The creator may also rename the Session. Close, archive, delete, -unarchive and cwd operations remain gated: the Runtime Broker's drain only -stops warming a closed Session and has no Harness-level teardown yet. +deployment without the opt-in, keeps the existing refusal: +`workspace_unavailable` when the actor can read the Workspace, +`session_not_found` when they cannot. The creator may also rename the Session. +Close, archive, delete, unarchive and cwd operations remain gated: the Runtime +Broker's drain only stops warming a closed Session and has no Harness-level +teardown yet. ## Decisions diff --git a/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md b/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md index 18d59b8511e..c8c5450af65 100644 --- a/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md +++ b/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md @@ -8,9 +8,9 @@ Hosted Read/Write/Edit 已能经过生产 Broker 和 worker 执行,但目前只有私有集成测试把它接到持久 Workspace 会话。公开创建在 service 和 SQL store 两层拒绝初始输入;coordinator 也拒绝有绑定的会话。Java connector 不传工具 profile,所有 Session Store 连接均使用部署的全局 Workspace。 -G0 开放随创建会话准入的一次初始文件工具轮次,使用现有公开 REST 路由及共享 service 的 WebShell 创建适配器。后续提交、取消、重命名、生命周期与 cwd 操作保持现有 Workspace 门禁。发现接口仍仅广播 Workspace 绑定能力,不宣称完整的 Workspace 执行支持。无需修改 UI。 +G0 开放随创建会话准入的一次初始文件工具轮次,使用现有公开 REST 路由及共享 service 的 WebShell 创建适配器。下文的后续改动在同一开关下为会话创建者开放后续 Turn 以及取消与重命名;生命周期与 cwd 操作保持现有 Workspace 门禁。发现接口仍仅广播 Workspace 绑定能力,不宣称完整的 Workspace 执行支持。G0 本身无需修改 UI;后续改动唯一的 UI 变化是为创建者开放输入框。 -后续改动在同一开关下为会话创建者开放后续 Turn,并允许创建者取消正在运行的 Turn,由 Hosted Harness 中止该 Turn 并按原始 Runtime 身份结算。执行时每个 Turn 都按创建者的 Workspace 授权校验,因此其他 actor 以及未开启该开关的部署仍得到现有的 `workspace_unavailable` 拒绝。创建者也可以重命名该会话。关闭、归档、删除、取消归档与 cwd 操作仍保持门禁:Runtime Broker 的 drain 只是停止为已关闭会话预热,尚无 Harness 级别的回收。 +后续改动在同一开关下为会话创建者开放后续 Turn,并允许创建者取消正在运行的 Turn,由 Hosted Harness 中止该 Turn 并按原始 Runtime 身份结算。执行时每个 Turn 都按创建者的 Workspace 授权校验,因此其他 actor 以及未开启该开关的部署仍得到现有拒绝:actor 可读取该 Workspace 时为 `workspace_unavailable`,不能读取时为 `session_not_found`。创建者也可以重命名该会话。关闭、归档、删除、取消归档与 cwd 操作仍保持门禁:Runtime Broker 的 drain 只是停止为已关闭会话预热,尚无 Harness 级别的回收。 ## 决策 diff --git a/packages/sdk-java/managed-agent-server/README.md b/packages/sdk-java/managed-agent-server/README.md index 71f669560b9..95b915257a0 100644 --- a/packages/sdk-java/managed-agent-server/README.md +++ b/packages/sdk-java/managed-agent-server/README.md @@ -60,8 +60,8 @@ Actions (Hosted permission approvals): [English](../../../docs/design/2026-09-30 - MySQL 8 Run the packaged CLI with `qwen serve --profile hosted-harness` as a separate -process. It supports durable no-tool Sessions and the opt-in initial Workspace -file Turn described in the G0 section below. +process. It supports durable no-tool Sessions and the opt-in Workspace file +Turns described in the G0 section below. Install the two sibling libraries once when building this module outside a Maven reactor: @@ -304,8 +304,10 @@ creation with input, including replays, while empty bound creation remains available. The directory mounted for a Workspace is trusted deployment data, not a filesystem sandbox. -Later submit/cancel/lifecycle/cwd operations and broad Workspace capability -advertisement remain gated. Shell and in-flight recovery are separate slices. +Later Turns may be submitted and cancelled by the Session's creator under the +same opt-in, and the creator may rename the Session. Close, archive, delete, +unarchive and cwd operations and broad Workspace capability advertisement +remain gated. Shell and in-flight recovery are separate slices. The existing `EmbeddedRuntimeBroker` is used through production configuration; no direct store admission or test Broker replacement is needed. @@ -435,9 +437,11 @@ does not provide physical isolation or recovery after worker-only death. Public bound Turn admission is limited to the opt-in initial file Turn described in G0 above and to later Turns submitted by the Session's creator under the same opt-in; the creator may also cancel them and rename the Session. Later Turns run -under the creator's Workspace grants, so any other actor keeps the -`workspace_unavailable` refusal. Public close, archive, delete and unarchive -remain gated; the private Shell profile is not enabled through public creation. +under the creator's Workspace grants, so any other actor keeps the existing +refusal: `workspace_unavailable` when the actor can read the Workspace, +`session_not_found` when they cannot. Public close, archive, delete and +unarchive remain gated; the private Shell profile is not enabled through public +creation. See the bilingual [execution design](../../../docs/design/2026-09-26-managed-workspace-execution.md) for the exact boundary. From 2f4abb19fce67d7d9cbdc2def42527a3df1dfb06 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 1 Oct 2026 06:55:46 +0800 Subject: [PATCH 10/73] chore(managed-agent): Bump the public API contract to 1.27.0 The deferral resolved: #13101 merged and took 1.25.0, and #13117 took 1.26.0. Record this PR's contract change per the version-history convention: creator later-Turn admission and the workspaceTurns capability. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmuoob5v90j --- .../resources/openapi/managed-agent-public-api.openapi.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json index 10c52836409..bbaea707369 100644 --- a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json +++ b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json @@ -2,8 +2,8 @@ "openapi": "3.1.0", "info": { "title": "Qwen Managed Agent Public and WebShell API", - "version": "1.26.0", - "description": "Canonical contract for the public Managed Agent resources and the WebShell adapter. partial means that the route exists but generated types and full contract tests are pending; planned means that the route or field is not implemented. Workspace context and cwd operations are v1.9 target contracts, not current server capabilities. v1.10 adds planned command admission, Actions, lifecycle and actor authorization. v1.12 refines planned W0 creation discovery, actor-specific create hints and explicit default selection. Full tool-result descriptors from the v1.11 design are not yet included. Filter planned fields as well as routes from production SDKs. v1.13 moves this contract into the Qwen Code repository as its single source and records the shipped rename, archive, unarchive and delete routes as partial. v1.14 integrates W0d workspace discovery, the WebShell workspace lookup, and empty-session binding as partial; workspace_binding/workspaceBinding advertises this narrow flow without enabling workspace execution or context switching. v1.15 adds the archived, archiving and deleting Session statuses and the cancelling Turn status, requires request_id in error envelopes, declares the error responses that the server returns, and marks Session create, list and get on both surfaces implemented. v1.16 adds the planned Stage H task contract (SessionTaskView as PublicTask, task list, detail, events and cancel) and names the MCP catalog, hook catalog, automation and channel resources as planned; their shapes arrive with Stages H1 to H6. v1.17 implements event replay: events carry their schema and projection versions and a top-level Item and Part identity, JSON event pages return has_more and next_cursor and accept limits up to 1000, and a persisted replay floor answers expired cursors with 409 cursor_expired in JSON and one agent.session.resync_required frame over SSE; it marks the public event query and stream, the WebShell event stream and the WebShell transcript implemented. v1.18 implements the durable Session lifecycle (Stage D4): close, archive and delete answer 202 with a command operation on both surfaces, an archive requires a closed Session, a deleted Session leaves a tombstone whose operations stay readable, and the operation query serves them; unarchive restores a closed Session, and Session capabilities advertise session_lifecycle. v1.19 serves the task list and detail on both surfaces as partial (Stage H0c): the Session store projects them from the Stage H records the Session authority commits, a task.updated Session event announces each change of a task's view, and Session capabilities advertise tasks. Task events and cancel stay planned until a slice's tasks produce output and accept a cancel. v1.20 implements the Turn read model (Stage D5): the public Turn list and detail read a Session's Turns without their input, newest first by creation time in milliseconds and then Turn ID, with an opaque cursor; a deleted Session's Turns are not readable, and the durable admission fields of a Turn stay planned until D7. v1.21 declares on the task list, detail and event routes of both surfaces the 403 actor_scope_mismatch that the tenant filter answers on every /v1/agents/ and WebShell route, as the Session and Turn reads already do, and the shared Forbidden response names it; a caller that cannot read a task still gets 404. v1.22 adds deployment-opted-in creation with an initial Workspace file-tool Turn (G0) on the public and WebShell surfaces; the fixed server-owned profile does not enable later Workspace operations. v1.23 settles the planned task event and cancel semantics: a durable retention floor, committed-prefix publication, stable cursors, Artifact visibility before expiry, bounded backlog under archival failure, ordered idempotent cancellation and command outcomes. Clients tolerate unknown optional event fields across minor versions. Task events and cancel remain planned. v1.24 serves the read-only Session MCP catalog as partial (Stage H1), projecting display metadata and schemas from committed records without Runtime identities, credentials or production profile enablement. v1.25 implements permission Actions (Stage D6b) on both surfaces: list, get and respond, projected from the Session's action.changed records; a WebShell permission Action carries inputRevision, policyRevision, functionCallId, toolName and expiresAt, its option ids allow and deny are stable, a Turn has at most one requested approval at a time, and a response is a durable command operation carrying requestId. v1.26 declares the tenant filter's 403 actor_scope_mismatch on every covered public and WebShell route, including planned routes, and pins the refusal code in contract traffic." + "version": "1.27.0", + "description": "Canonical contract for the public Managed Agent resources and the WebShell adapter. partial means that the route exists but generated types and full contract tests are pending; planned means that the route or field is not implemented. Workspace context and cwd operations are v1.9 target contracts, not current server capabilities. v1.10 adds planned command admission, Actions, lifecycle and actor authorization. v1.12 refines planned W0 creation discovery, actor-specific create hints and explicit default selection. Full tool-result descriptors from the v1.11 design are not yet included. Filter planned fields as well as routes from production SDKs. v1.13 moves this contract into the Qwen Code repository as its single source and records the shipped rename, archive, unarchive and delete routes as partial. v1.14 integrates W0d workspace discovery, the WebShell workspace lookup, and empty-session binding as partial; workspace_binding/workspaceBinding advertises this narrow flow without enabling workspace execution or context switching. v1.15 adds the archived, archiving and deleting Session statuses and the cancelling Turn status, requires request_id in error envelopes, declares the error responses that the server returns, and marks Session create, list and get on both surfaces implemented. v1.16 adds the planned Stage H task contract (SessionTaskView as PublicTask, task list, detail, events and cancel) and names the MCP catalog, hook catalog, automation and channel resources as planned; their shapes arrive with Stages H1 to H6. v1.17 implements event replay: events carry their schema and projection versions and a top-level Item and Part identity, JSON event pages return has_more and next_cursor and accept limits up to 1000, and a persisted replay floor answers expired cursors with 409 cursor_expired in JSON and one agent.session.resync_required frame over SSE; it marks the public event query and stream, the WebShell event stream and the WebShell transcript implemented. v1.18 implements the durable Session lifecycle (Stage D4): close, archive and delete answer 202 with a command operation on both surfaces, an archive requires a closed Session, a deleted Session leaves a tombstone whose operations stay readable, and the operation query serves them; unarchive restores a closed Session, and Session capabilities advertise session_lifecycle. v1.19 serves the task list and detail on both surfaces as partial (Stage H0c): the Session store projects them from the Stage H records the Session authority commits, a task.updated Session event announces each change of a task's view, and Session capabilities advertise tasks. Task events and cancel stay planned until a slice's tasks produce output and accept a cancel. v1.20 implements the Turn read model (Stage D5): the public Turn list and detail read a Session's Turns without their input, newest first by creation time in milliseconds and then Turn ID, with an opaque cursor; a deleted Session's Turns are not readable, and the durable admission fields of a Turn stay planned until D7. v1.21 declares on the task list, detail and event routes of both surfaces the 403 actor_scope_mismatch that the tenant filter answers on every /v1/agents/ and WebShell route, as the Session and Turn reads already do, and the shared Forbidden response names it; a caller that cannot read a task still gets 404. v1.22 adds deployment-opted-in creation with an initial Workspace file-tool Turn (G0) on the public and WebShell surfaces; the fixed server-owned profile does not enable later Workspace operations. v1.23 settles the planned task event and cancel semantics: a durable retention floor, committed-prefix publication, stable cursors, Artifact visibility before expiry, bounded backlog under archival failure, ordered idempotent cancellation and command outcomes. Clients tolerate unknown optional event fields across minor versions. Task events and cancel remain planned. v1.24 serves the read-only Session MCP catalog as partial (Stage H1), projecting display metadata and schemas from committed records without Runtime identities, credentials or production profile enablement. v1.25 implements permission Actions (Stage D6b) on both surfaces: list, get and respond, projected from the Session's action.changed records; a WebShell permission Action carries inputRevision, policyRevision, functionCallId, toolName and expiresAt, its option ids allow and deny are stable, a Turn has at most one requested approval at a time, and a response is a durable command operation carrying requestId. v1.26 declares the tenant filter's 403 actor_scope_mismatch on every covered public and WebShell route, including planned routes, and pins the refusal code in contract traffic. v1.27 admits later Turns of a Workspace-bound Session for the Session's creator under the deployment's Workspace files opt-in, including cancel and rename; Session capabilities advertise the per-caller workspaceTurns flag." }, "servers": [ { From af0373cabb757f245237c63ce3db7637d9ec0b4b Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 1 Oct 2026 07:49:04 +0800 Subject: [PATCH 11/73] fix(web-shell): hide execution-unavailable banner line when creator can send later Turns The workspace-binding banner always stated that message execution is unavailable, contradicting the composer this PR enables for the creator of a bound Session with workspaceTurns capability. Gate the third banner paragraph on !capabilities.workspaceTurns so the page no longer states both. Pin both branches in ManagedSessionsPage tests. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmuoqgbnv0l --- .../components/managed/ManagedSessionsPage.test.tsx | 9 +++++++++ .../client/components/managed/ManagedSessionsPage.tsx | 8 +++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx b/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx index 6f1be766759..8fe14d16a35 100644 --- a/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx +++ b/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx @@ -226,6 +226,9 @@ describe('ManagedSessionsPage', () => { expect( container.querySelector('[data-managed-workspace-binding]')?.textContent, ).toContain('services/api'); + expect( + container.querySelector('[data-managed-workspace-binding]')?.textContent, + ).toContain('Message execution is not available'); expect(container.querySelector('[data-managed-progress]')).toBeNull(); expect(container.querySelector('textarea')).toBeNull(); expect(container.textContent).not.toContain('Preparing environment'); @@ -245,6 +248,12 @@ describe('ManagedSessionsPage', () => { }); await render('bound'); + expect( + container.querySelector('[data-managed-workspace-binding]')?.textContent, + ).toContain('ws-a'); + expect( + container.querySelector('[data-managed-workspace-binding]')?.textContent, + ).not.toContain('Message execution is not available'); expect(container.querySelector('textarea')).not.toBeNull(); await input('Run it again'); await click('Send'); diff --git a/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx b/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx index e5c56aba9bf..9d82fef1a8a 100644 --- a/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx +++ b/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx @@ -435,9 +435,11 @@ function ManagedSessionsContent({ {t('managed.workspaceDirectory')}:{' '} {summary.workspace.cwdRelative}

-

- {t('managed.workspaceExecutionUnavailable')} -

+ {!summary.capabilities.workspaceTurns && ( +

+ {t('managed.workspaceExecutionUnavailable')} +

+ )} )} {summary?.failure && ( From 3a736a37cee295a58d0bb986d659993d74593928 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 1 Oct 2026 08:05:15 +0800 Subject: [PATCH 12/73] test(sdk-java): pin bound-Session admission clauses with negative controls Three of the four clauses this PR adds to bound-Session admission could be deleted with the suite staying green: - ManagedWorkspaceRegistry.createdSession's session_id column: create a second bound Session by actor-b under a distinct idempotency key and assert createdSession(tenant, actor-a, otherSession) is false, so the lookup must match this Session, not any Session the actor created. - maySubmitWorkspaceTurn's harness.isWorkspaceFilesAvailable() clause: assert the bound-session web-shell GET reports capabilities.workspaceTurns == false while the opt-in is off. - boundRenameAllowed's kind conjunct: assert beginSessionMutation with UNARCHIVE on the enabled store throws workspace_unavailable, and add the positive insertCancelCommand control so the cancel path stays open for bound Sessions under the opt-in. Each new assertion was verified to go red under the corresponding mutation (session_id dropped, flag clause dropped, kind conjunct dropped, cancel conjunct dropped) and the suite returns green with the sources restored. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmuoqgbnv0l --- .../ManagedWorkspaceAdmissionTest.java | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java index b3769b1fb0a..5ce1de455f9 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java @@ -552,6 +552,15 @@ void enabledStoreAdmitsALaterTurnForTheBoundSessionCreator() { .isFalse(); assertThat(registry.createdSession("tenant-" + UUID.randomUUID(), "actor-a", sessionId)).isFalse(); + // The lookup pins this Session, not any bound Session the actor + // created in the tenant: actor-b's own Session does not admit + // actor-a, and vice versa. + String otherSession = store.insertWorkspaceSessionCommand(tenant, + "actor-b", "create-b", digest, "qwen-code", null, null, + List.of(), null, new WorkspaceSelection("ws-a", ".")) + .sessionId(); + assertThat(registry.createdSession(tenant, "actor-a", otherSession)) + .isFalse(); ManagedAgentProperties enabled = new ManagedAgentProperties(); enabled.getHarness().setWorkspaceFilesEnabled(true); @@ -569,6 +578,19 @@ void enabledStoreAdmitsALaterTurnForTheBoundSessionCreator() { + " managed_agent_turn WHERE tenant_id = ?" + " AND session_id = ?", Integer.class, tenant, sessionId)).isEqualTo(1); + // Unarchive stays gated for bound Sessions even under the opt-in; + // the enabled store opens rename only. + assertThatThrownBy(() -> transaction.execute(status -> + gated.beginSessionMutation(tenant, "UNARCHIVE_SESSION", + "unarchive-1", digest, sessionId, + SessionMutationKind.UNARCHIVE))) + .isInstanceOfSatisfying(ApiException.class, error -> + assertThat(error.getCode()) + .isEqualTo("workspace_unavailable")); + // Cancel stays open for a bound Session under the opt-in. + assertThat(gated.insertCancelCommand(tenant, "CANCEL", "cancel-1", + digest, sessionId, admission.turnId()).turnId()) + .isEqualTo(admission.turnId()); } @Test @@ -671,7 +693,11 @@ void webShellCreationIsMetadataOnlyUntilExecutionIsWired() .andExpect(status().isOk()) .andExpect(jsonPath("$.workspace.workspaceId") .value("ws-a")) - .andExpect(jsonPath("$.workspace.cwdRelative").value("services/api")); + .andExpect(jsonPath("$.workspace.cwdRelative").value("services/api")) + // The opt-in is off, so even the creator may not send later + // Turns; this pins the isWorkspaceFilesAvailable clause. + .andExpect(jsonPath("$.capabilities.workspaceTurns") + .value(false)); mvc.perform(post("/api/agent/web-shell/v1/sessions/create") .header(TenantContextFilter.HEADER, tenant) .principal(actor(tenant, "actor-a")) From 5d4499cf95cd5155ad0735c60bf0f0c687c6b58a Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 1 Oct 2026 08:09:44 +0800 Subject: [PATCH 13/73] test(sdk-java): exercise later Turns under approval-mode=default and pin canRead HostedPublicWorkspaceIT changes: - register() now grants the reader in both runs (can_create only in the files run), removing the access-table primary-key collision that forced the approvals run to skip the later-Turn block. - The approvals run now drives a later Turn through answerActions and asserts it completes, so a later Turn admitted under approval-mode=default is covered; the cancel/rename probes keep their held model reply and stay in the files run to fit the method timeout. - proof.txt is reset to a sentinel just before the later-Turn submit, so the post-Turn "after" assertion can fail if the later Turn's write and edit stop reaching the bound root (R1-13). - After the rename check, revoking the creator's read grant asserts submit, cancel and PATCH all answer 404 session_not_found, pinning the canRead clause of maySubmitWorkspaceTurn (R1-12 clause 3). - Model-request counts become 16 (approvals) / 18 (files) and the approvals run now answers 8 actions across the two Turns. Compile-verified via mvn test-compile. Execution requires the bundled dist/cli.js plus a MySQL/hosted-Harness stack, which this environment cannot provide; behavior was traced against requireSubmitter / requireLegacyWorkspace / boundRenameAllowed and the fixture request model, and CI remains the executor. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmuoqgbnv0l --- .../managedagent/HostedPublicWorkspaceIT.java | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java index 9cf7b08f07e..4b69908a28f 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java @@ -76,7 +76,7 @@ void publicCreationRunsFilesThroughProductionWorkspaceBinding() throws Exception void ownerAnswersHostedApprovalsThroughBothSurfaces() throws Exception { approvals = true; runFiles(); - assertThat(answered).hasSize(4); + assertThat(answered).hasSize(8); } private void runFiles() throws Exception { @@ -166,14 +166,8 @@ private void runFiles() throws Exception { assertThat(request("POST", route, changed, workspace, "actor", 409).path("error").path("code").asText()) .isEqualTo("idempotency_conflict"); request("GET", "/v1/agents/sessions/" + session, null, null, "other", 404); - // The approval run registers its own reader and answers only the initial Turn; - // later Turns are covered by the files run. - if (approvals) continue; // A later Turn runs under the creator's grants: another actor who can read the // Session keeps the refusal, and the creator's second Turn runs the file tools again. - jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read," - + " can_create) VALUES (?, ?, ?, TRUE, TRUE)", tenant, workspace, - "reader".getBytes(StandardCharsets.UTF_8)); Map later = Map.of("type", "agent.session.input.message", "input", List.of(Map.of("type", "input_text", "text", "G0_AGAIN"))); assertThat(request("POST", "/v1/agents/sessions/" + session + "/events", later, @@ -185,6 +179,8 @@ private void runFiles() throws Exception { null, caller, 200).path("capabilities").path("workspaceTurns").asBoolean()) .as(caller).isEqualTo("actor".equals(caller)); } + // Only the later Turn can restore this; the initial Turn asserted "after" above. + Files.writeString(roots.get(index).resolve("child/proof.txt"), "x"); String laterTurn = request("POST", "/v1/agents/sessions/" + session + "/events", later, "later-" + workspace, "actor", 202).path("turn_id").asText(); assertThat(laterTurn).isNotBlank(); @@ -196,6 +192,7 @@ private void runFiles() throws Exception { + Files.readString(temporary.resolve("harness.log"))); } }).untilAsserted(() -> { + if (approvals) answerActions(session, webShell); assertThat(modelFailure.get()).isNull(); assertThat(jdbc.queryForObject("SELECT status FROM managed_agent_turn" + " WHERE session_id = ? AND turn_id = ?", String.class, session, laterTurn)) @@ -206,6 +203,10 @@ private void runFiles() throws Exception { assertThat(modelRequests).hasSize(requests + 4); assertThat(Files.readString(roots.get(index).resolve("child/proof.txt"))).isEqualTo("after"); assertThat(decoy.resolve("proof.txt")).doesNotExist(); + // The cancel and rename probes below keep a held model reply, so they stay in + // the files run to fit the method timeout; the approvals run has already pinned + // that a later Turn under approval-mode=default is admitted and completes. + if (approvals) continue; // The creator can cancel a running later Turn; the Hosted Harness aborts it before // any tool runs. Another reader keeps the refusal. @@ -235,8 +236,27 @@ private void runFiles() throws Exception { "reader", 409).path("error").path("code").asText()).isEqualTo("workspace_unavailable"); assertThat(request("PATCH", "/v1/agents/sessions/" + session, rename, "rename-" + workspace, "actor", 200).path("metadata").path("title").asText()).isEqualTo("Renamed " + workspace); + + // With the running Turn settled, revoking the creator's read grant hides the + // bound Session from every later-Turn path: submit, cancel and rename all fall + // through to the legacy gate and answer session_not_found. + jdbc.update("UPDATE managed_workspace_access SET can_read = FALSE" + + " WHERE tenant_id = ? AND workspace_id = ? AND actor_id = ?", + tenant, workspace, "actor".getBytes(StandardCharsets.UTF_8)); + assertThat(request("POST", "/v1/agents/sessions/" + session + "/events", later, + "revoked-later-" + workspace, "actor", 404).path("error").path("code").asText()) + .isEqualTo("session_not_found"); + assertThat(request("POST", "/v1/agents/sessions/" + session + "/events", cancel, + "revoked-cancel-" + workspace, "actor", 404).path("error").path("code").asText()) + .isEqualTo("session_not_found"); + assertThat(request("PATCH", "/v1/agents/sessions/" + session, rename, + "revoked-rename-" + workspace, "actor", 404).path("error").path("code").asText()) + .isEqualTo("session_not_found"); + jdbc.update("UPDATE managed_workspace_access SET can_read = TRUE" + + " WHERE tenant_id = ? AND workspace_id = ? AND actor_id = ?", + tenant, workspace, "actor".getBytes(StandardCharsets.UTF_8)); } - assertThat(modelRequests).hasSize(approvals ? 8 : 18); + assertThat(modelRequests).hasSize(approvals ? 16 : 18); assertThat(modelFailure.get()).isNull(); Map denied = Map.of("agent_id", "qwen-code", "workspace", Map.of("workspace_id", "workspace-0"), "input", List.of(Map.of("type", "input_text", "text", "G0_FILES"))); @@ -269,7 +289,7 @@ private void runFiles() throws Exception { assertUnavailable(request("POST", "/v1/agents/sessions", denied, "unsupported", "actor", 409)); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_session WHERE tenant_id = ?", Integer.class, tenant)).isEqualTo(2); - assertThat(modelRequests).hasSize(approvals ? 8 : 18); + assertThat(modelRequests).hasSize(approvals ? 16 : 18); } private void startSpring(Path cli, List roots, int harnessPort, int brokerPort) { @@ -336,8 +356,11 @@ private void register(String workspace, String storage) { tenant, workspace, storage, WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF); jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)" + " VALUES (?, ?, ?, TRUE, TRUE)", tenant, workspace, "actor".getBytes(StandardCharsets.UTF_8)); - if (approvals) jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create) VALUES (?, ?, ?, TRUE, FALSE)", - tenant, workspace, "reader".getBytes(StandardCharsets.UTF_8)); + // The reader grant exists in both runs so the later-Turn block can also run under + // approval-mode=default without colliding with the access table's primary key. + jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)" + + " VALUES (?, ?, ?, TRUE, ?)", tenant, workspace, "reader".getBytes(StandardCharsets.UTF_8), + !approvals); } private void answerActions(String session, boolean web) throws Exception { From 26de98cd7e3a6768aea9a0ca71c1706f77149335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Thu, 1 Oct 2026 14:03:08 +0800 Subject: [PATCH 14/73] fix(sdk-java): align bound-Session later-Turn admission with the execution authority Round-2 review of #13112 found the admission predicate certifying Turns that execution can never run. maySubmitWorkspaceTurn now also requires the predicates the cited authority fixes at creation (Session ACTIVE, the qwen-code agent, the frozen execution profile refs) and the creator's can_create on an ACTIVE registry, read through the same grant row shape; a can_read-revoked creator still falls through to the documented 404. Cancellation attaches passively so an abort no longer depends on the physical mount still verifying. renameSession answers a non-retryable refusal with its own status and code instead of a transient 503. requireSubmitter loads the Session once per call, and the session read paths skip the canRead probe their entry already established. The OpenAPI contract, README and both design docs now state the creator admission, its grants qualifier and the still-gated lifecycle/cwd operations consistently, and the generated WebShell types are regenerated from the corrected contract. Tests pin the bound rename admission, the two empty-creation profile escapes and the can_create-revoked arm. Co-authored-by: Qwen-Coder --- ...09-29-hosted-public-workspace-admission.md | 24 +++--- ...hosted-public-workspace-admission.zh-CN.md | 9 +-- .../sdk-java/managed-agent-server/README.md | 10 ++- .../service/HarnessCoordinator.java | 6 +- .../service/ManagedAgentService.java | 72 +++++++++++++++--- .../managed-agent-public-api.openapi.json | 8 +- .../managedagent/HostedPublicWorkspaceIT.java | 23 ++++++ .../ManagedWorkspaceAdmissionTest.java | 74 ++++++++++++++++++- .../service/HarnessCoordinatorTest.java | 5 +- .../managed/generated/managed-agent-api.ts | 4 +- 10 files changed, 193 insertions(+), 42 deletions(-) diff --git a/docs/design/2026-09-29-hosted-public-workspace-admission.md b/docs/design/2026-09-29-hosted-public-workspace-admission.md index 1fc2cf8eaa9..598b6269d97 100644 --- a/docs/design/2026-09-29-hosted-public-workspace-admission.md +++ b/docs/design/2026-09-29-hosted-public-workspace-admission.md @@ -14,11 +14,10 @@ and uses the deployment's global Workspace for every Session Store connection. G0 enables one initial file-tool Turn admitted with Session creation. It uses the existing public REST route and the WebShell creation adapter that shares its -service. The follow-up below admits later Turns for the Session's creator under -the same opt-in, including cancel and rename; lifecycle and cwd operations -retain their existing Workspace gates. Discovery continues to advertise only +service. Discovery continues to advertise only Workspace binding, not complete Workspace execution support. G0 requires no UI -changes; the follow-up's only UI change is enabling the creator's composer. +changes; the follow-up's only UI changes are enabling the creator's composer and +its Cancel control. A follow-up admits later Turns for the Session's creator under the same opt-in, and lets the creator cancel a running Turn, which the Hosted Harness aborts and @@ -68,18 +67,17 @@ teardown yet. | Existing Broker/worker | Reuse production routing and fencing | Selected Runtime and persisted Workspace | | Contract and README | Document the narrow creation capability and remaining gates | Public REST and WebShell adapter | -Production behavior changes only under `packages/sdk-java/managed-agent-server` -and in the private Hosted DTOs in `packages/sdk-java/qwencode`; it stays limited -to the initial Workspace Read/Write/Edit Turn. No core authority, tool +Production behavior changes under `packages/sdk-java/managed-agent-server`, in +the private Hosted DTOs in `packages/sdk-java/qwencode`, and in the WebShell +managed Sessions page and its providers (`packages/web-shell`); it covers the +initial Workspace Read/Write/Edit Turn and the creator's later-Turn submit, +cancel and rename admission. No core authority, tool execution loop, database schema or public request field needs a new abstraction. -The merged change also touched three places outside that scope, none of which +The merged change also touched two places outside that scope, neither of which adds runtime behavior: -- **Generated WebShell types.** `packages/web-shell` regenerates - `managed-agent-api.ts` from the updated OpenAPI descriptions; only the - documentation comments change. - **Runtime Broker fault gate.** `DurableLocalRuntimeFaultGateTest` holds the worker's `execute` response in its fault proxy, so the first Broker cannot record the result before it is killed. The replacement Broker's `acquire` @@ -105,7 +103,9 @@ key and verify the same Session/Turn and no extra model/tool effects. Verify a different payload conflicts, unauthorized tenants/actors cannot create or read, unsupported profiles and unavailable Workspaces refuse, and disabling the opt-in preserves the current gate. Exercise the shared WebShell create adapter, -unchanged later-operation gates, and unbound no-tool regression paths. +the later-operation gates that changed (the creator's later-Turn submit, cancel +and rename are admitted; lifecycle and cwd operations stay gated), and unbound +no-tool regression paths. Focused SDK serialization, connector, store/admission and coordinator tests cover create/load identity, authorization rechecks and disabled gates. Run the diff --git a/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md b/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md index c8c5450af65..1956a0225ac 100644 --- a/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md +++ b/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md @@ -8,7 +8,7 @@ Hosted Read/Write/Edit 已能经过生产 Broker 和 worker 执行,但目前只有私有集成测试把它接到持久 Workspace 会话。公开创建在 service 和 SQL store 两层拒绝初始输入;coordinator 也拒绝有绑定的会话。Java connector 不传工具 profile,所有 Session Store 连接均使用部署的全局 Workspace。 -G0 开放随创建会话准入的一次初始文件工具轮次,使用现有公开 REST 路由及共享 service 的 WebShell 创建适配器。下文的后续改动在同一开关下为会话创建者开放后续 Turn 以及取消与重命名;生命周期与 cwd 操作保持现有 Workspace 门禁。发现接口仍仅广播 Workspace 绑定能力,不宣称完整的 Workspace 执行支持。G0 本身无需修改 UI;后续改动唯一的 UI 变化是为创建者开放输入框。 +G0 开放随创建会话准入的一次初始文件工具轮次,使用现有公开 REST 路由及共享 service 的 WebShell 创建适配器。发现接口仍仅广播 Workspace 绑定能力,不宣称完整的 Workspace 执行支持。G0 本身无需修改 UI;后续改动唯一的 UI 变化是为创建者开放输入框与取消控件。 后续改动在同一开关下为会话创建者开放后续 Turn,并允许创建者取消正在运行的 Turn,由 Hosted Harness 中止该 Turn 并按原始 Runtime 身份结算。执行时每个 Turn 都按创建者的 Workspace 授权校验,因此其他 actor 以及未开启该开关的部署仍得到现有拒绝:actor 可读取该 Workspace 时为 `workspace_unavailable`,不能读取时为 `session_not_found`。创建者也可以重命名该会话。关闭、归档、删除、取消归档与 cwd 操作仍保持门禁:Runtime Broker 的 drain 只是停止为已关闭会话预热,尚无 Harness 级别的回收。 @@ -32,11 +32,10 @@ G0 开放随创建会话准入的一次初始文件工具轮次,使用现有 | 现有 Broker/worker | 复用生产路由与 fencing | 所选 Runtime 及持久 Workspace | | 契约与 README | 记录有限的创建能力及剩余门禁 | 公开 REST 与 WebShell 适配器 | -生产行为只在 `packages/sdk-java/managed-agent-server` 和 `packages/sdk-java/qwencode` 的私有 Hosted DTO 中变化,且仅限于初始 Workspace Read/Write/Edit Turn。不需要为 core authority、工具执行循环、数据库 schema 或公开请求字段新增抽象。 +生产行为在 `packages/sdk-java/managed-agent-server`、`packages/sdk-java/qwencode` 的私有 Hosted DTO,以及 WebShell 托管会话页及其 provider(`packages/web-shell`)中变化,覆盖初始 Workspace Read/Write/Edit Turn 与创建者后续 Turn 的提交、取消和重命名准入。不需要为 core authority、工具执行循环、数据库 schema 或公开请求字段新增抽象。 -合入的改动还涉及该范围之外的三处,均不增加运行时行为: +合入的改动还涉及该范围之外的两处,均不增加运行时行为: -- **生成的 WebShell 类型。** `packages/web-shell` 根据更新后的 OpenAPI 描述重新生成 `managed-agent-api.ts`,只有文档注释变化。 - **Runtime Broker 故障门禁。** `DurableLocalRuntimeFaultGateTest` 在故障代理中扣住 worker 的 `execute` 响应,使第一个 Broker 在被终止前无法记录结果。随后替换 Broker 的 `acquire` 通过 #12964 的接管对账结算该调用,测试断言这一结果(`ALREADY_SETTLED`,且只有一次物理执行),而不再同时接受取决于时序的已结算或已解决两种状态。 - **Core resume 测试。** `background-agent-resume.test.ts` 的一个用例把 Skill 工具报告为已注册,使其列表断言不会空洞通过。该覆盖目前仍在 `main` 上。 @@ -44,7 +43,7 @@ G0 开放随创建会话准入的一次初始文件工具轮次,使用现有 使用确定性的本地模型和打包 CLI,运行真实 Spring coordinator、SQL Store、按部署配置启动的生产 Broker 及独立 worker。仅模型和可信网关 principal 使用测试夹具。Workspace registry 和 grants 作为部署数据预置;会话必须通过公开 HTTP 创建。 -初始轮次必须在所选 Workspace 的相对 cwd 下写、编辑并读取文件,产生持久工具历史和恰好一个公开终态事件,且不改动 Harness 的诱饵目录。重复创建幂等键,验证相同 Session/Turn 且无额外模型/工具副作用。验证改变载荷冲突、未授权租户/actor 无法创建或读取、不支持的 profile 与不可用 Workspace 被拒绝,以及关闭开关后保持原门禁。覆盖共享 WebShell 创建适配器、未改变的后续操作门禁和无绑定无工具回归路径。 +初始轮次必须在所选 Workspace 的相对 cwd 下写、编辑并读取文件,产生持久工具历史和恰好一个公开终态事件,且不改动 Harness 的诱饵目录。重复创建幂等键,验证相同 Session/Turn 且无额外模型/工具副作用。验证改变载荷冲突、未授权租户/actor 无法创建或读取、不支持的 profile 与不可用 Workspace 被拒绝,以及关闭开关后保持原门禁。覆盖共享 WebShell 创建适配器、实际发生变化的后续操作门禁(创建者的后续 Turn 提交、取消与重命名被放行,生命周期与 cwd 操作仍受限)和无绑定无工具回归路径。 SDK 序列化、connector、store/准入及 coordinator 的定向测试覆盖 create/load 身份、权限复核与关闭的门禁。本地通过 H2 跑 Hosted 集成,并加入现有 Hosted MySQL CI 套件;单独记录本地 MySQL 是否可用。完成前执行 build、typecheck、bundle、定向测试和两轮无发现的完整 diff 自查。 diff --git a/packages/sdk-java/managed-agent-server/README.md b/packages/sdk-java/managed-agent-server/README.md index 95b915257a0..1060afc3149 100644 --- a/packages/sdk-java/managed-agent-server/README.md +++ b/packages/sdk-java/managed-agent-server/README.md @@ -304,8 +304,10 @@ creation with input, including replays, while empty bound creation remains available. The directory mounted for a Workspace is trusted deployment data, not a filesystem sandbox. -Later Turns may be submitted and cancelled by the Session's creator under the -same opt-in, and the creator may rename the Session. Close, archive, delete, +Later Turns may be submitted by the Session's creator under the +same opt-in while they can still read the Workspace (the per-caller +`workspaceTurns` capability flag reflects this), and the creator may cancel the +Session's running Turns and rename the Session. Close, archive, delete, unarchive and cwd operations and broad Workspace capability advertisement remain gated. Shell and in-flight recovery are separate slices. The existing `EmbeddedRuntimeBroker` is used through production configuration; @@ -436,7 +438,9 @@ local workloads. The opt-in W0e recovery above handles trusted host reboot; it does not provide physical isolation or recovery after worker-only death. Public bound Turn admission is limited to the opt-in initial file Turn described in G0 above and to later Turns submitted by the Session's creator under the same -opt-in; the creator may also cancel them and rename the Session. Later Turns run +opt-in while they can still read the Workspace (the per-caller `workspaceTurns` +capability flag reflects this); the creator may also cancel the Session's +running Turns and rename the Session. Later Turns run under the creator's Workspace grants, so any other actor keeps the existing refusal: `workspace_unavailable` when the actor can read the Workspace, `session_not_found` when they cannot. Public close, archive, delete and diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java index 540a317441c..667540d7696 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java @@ -550,9 +550,13 @@ private void cancelAdmittedTurn(String tenantId, String sessionId, && !harness.isWorkspaceFilesAvailable()) { return; } + // Attach passively, like the cancellation-recovery path above: + // an abort must not depend on the physical mount still + // verifying, or a cancel the API already answered would be + // dropped with only a WARN to show for it. Attachment attachment = harness.createOrLoad( session.tenantId(), session.sessionId(), - session.harnessBootId() != null); + session.harnessBootId() != null, true); if (store.bindHarness(tenantId, sessionId, turnId, owner, attachment.bootId())) { harness.cancel(session.tenantId(), session.sessionId()); diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java index 74db88d76ad..2d63a59c5c4 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java @@ -41,6 +41,8 @@ import com.alibaba.qwen.code.managedagent.store.StoreModels.TurnPage; import com.alibaba.qwen.code.managedagent.store.StoreModels.TurnRecord; import com.alibaba.qwen.code.managedagent.store.StoreModels.TurnSummary; +import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; +import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Base64; @@ -264,6 +266,19 @@ public SessionMutationResult renameSession( session.harnessBootId() != null); harness.rename(tenantId, sessionId, effectiveTitle); } catch (RuntimeException error) { + // A non-retryable refusal (e.g. the Workspace authority's) + // is permanent: answer it with its own status and code + // instead of a transient 503, which would invite a fresh-key + // retry into session_operation_active on the still-PENDING + // command. + if (error instanceof RuntimeBrokerException refusal + && !refusal.isRetryable()) { + HttpStatus status = HttpStatus.resolve( + refusal.getStatusCode()); + throw new ApiException( + status == null ? HttpStatus.CONFLICT : status, + refusal.getCode(), refusal.getMessage()); + } throw dependencyUnavailable("hosted_harness_unavailable", "The Hosted Harness could not persist the Session title."); } @@ -524,7 +539,7 @@ private WebShellSession webShellSession(SessionRecord session, // Every Session serves its task list and detail; the tasks come from the // Stage H records its Session store holds (H0c). new WebShellSessionCapabilities(true, hasActions(session), - maySubmitWorkspaceTurn(session, actorId))); + maySubmitWorkspaceTurn(session, actorId, true))); } private static WebShellWorkspace webShellWorkspace(SessionRecord session) { @@ -673,27 +688,60 @@ String lifecycleDigest(String sessionId, String operation) { // Workspace files enabled. Everyone else keeps the existing refusal. private void requireSubmitter(String tenantId, String actorId, String sessionId) { - if (!maySubmitWorkspaceTurn(store.requireSession(tenantId, sessionId), - actorId)) { - requireLegacyWorkspace(tenantId, actorId, sessionId); + SessionRecord session = store.requireSession(tenantId, sessionId); + if (!maySubmitWorkspaceTurn(session, actorId)) { + requireLegacyWorkspace(session, actorId); } } private boolean maySubmitWorkspaceTurn(SessionRecord session, String actorId) { - return session.workspace() != null - && harness.isWorkspaceFilesAvailable() - && workspaces.canRead(session.tenantId(), actorId, - session.workspace().getWorkspaceId()) - && workspaces.createdSession(session.tenantId(), actorId, - session.sessionId()); + return maySubmitWorkspaceTurn(session, actorId, false); + } + + // readGranted is true on the read paths (session get/list), where the + // page query or requireReadGrant already established the caller's + // can_read for a bound row, so the clause would re-ask a fixed true. + private boolean maySubmitWorkspaceTurn(SessionRecord session, + String actorId, boolean readGranted) { + if (session.workspace() == null || !harness.isWorkspaceFilesAvailable()) { + return false; + } + // The authority execution cites (WorkspaceExecutionStore + // .authorizePassiveAttachment) fixes these at creation: a Session + // that fails them can never execute, so admission must not certify + // it. Empty bound creation skips that validation by design. + if (!"ACTIVE".equals(session.status()) || session.deletedAt() != null + || !"qwen-code".equals(session.agentId()) + || !WorkspaceExecutionProfile.CONTEXT_CONFIG_REF.equals( + session.workspace().getContextConfigRef())) { + return false; + } + if ((!readGranted && !workspaces.canRead(session.tenantId(), actorId, + session.workspace().getWorkspaceId())) + || !workspaces.createdSession(session.tenantId(), actorId, + session.sessionId())) { + return false; + } + // The caller is the Session's creator, so this reads the creator's + // grant row, as the execution authority's join does: can_create on a + // registry whose state is ACTIVE. + ManagedWorkspaceRegistry.WorkspaceSummary summary = + workspaces.findReadable(session.tenantId(), actorId, + session.workspace().getWorkspaceId()); + return summary != null && summary.canCreateSession(); } void requireLegacyWorkspace(String tenantId, String actorId, String sessionId) { - SessionRecord session = store.requireSession(tenantId, sessionId); + requireLegacyWorkspace(store.requireSession(tenantId, sessionId), + actorId); + } + + private void requireLegacyWorkspace(SessionRecord session, + String actorId) { if (session.workspace() != null) { - if (!workspaces.canRead(tenantId, actorId, + if (!workspaces.canRead(session.tenantId(), actorId, session.workspace().getWorkspaceId())) { throw new ApiException(HttpStatus.NOT_FOUND, "session_not_found", "The Session was not found."); diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json index bbaea707369..b49e9abed73 100644 --- a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json +++ b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json @@ -3,7 +3,7 @@ "info": { "title": "Qwen Managed Agent Public and WebShell API", "version": "1.27.0", - "description": "Canonical contract for the public Managed Agent resources and the WebShell adapter. partial means that the route exists but generated types and full contract tests are pending; planned means that the route or field is not implemented. Workspace context and cwd operations are v1.9 target contracts, not current server capabilities. v1.10 adds planned command admission, Actions, lifecycle and actor authorization. v1.12 refines planned W0 creation discovery, actor-specific create hints and explicit default selection. Full tool-result descriptors from the v1.11 design are not yet included. Filter planned fields as well as routes from production SDKs. v1.13 moves this contract into the Qwen Code repository as its single source and records the shipped rename, archive, unarchive and delete routes as partial. v1.14 integrates W0d workspace discovery, the WebShell workspace lookup, and empty-session binding as partial; workspace_binding/workspaceBinding advertises this narrow flow without enabling workspace execution or context switching. v1.15 adds the archived, archiving and deleting Session statuses and the cancelling Turn status, requires request_id in error envelopes, declares the error responses that the server returns, and marks Session create, list and get on both surfaces implemented. v1.16 adds the planned Stage H task contract (SessionTaskView as PublicTask, task list, detail, events and cancel) and names the MCP catalog, hook catalog, automation and channel resources as planned; their shapes arrive with Stages H1 to H6. v1.17 implements event replay: events carry their schema and projection versions and a top-level Item and Part identity, JSON event pages return has_more and next_cursor and accept limits up to 1000, and a persisted replay floor answers expired cursors with 409 cursor_expired in JSON and one agent.session.resync_required frame over SSE; it marks the public event query and stream, the WebShell event stream and the WebShell transcript implemented. v1.18 implements the durable Session lifecycle (Stage D4): close, archive and delete answer 202 with a command operation on both surfaces, an archive requires a closed Session, a deleted Session leaves a tombstone whose operations stay readable, and the operation query serves them; unarchive restores a closed Session, and Session capabilities advertise session_lifecycle. v1.19 serves the task list and detail on both surfaces as partial (Stage H0c): the Session store projects them from the Stage H records the Session authority commits, a task.updated Session event announces each change of a task's view, and Session capabilities advertise tasks. Task events and cancel stay planned until a slice's tasks produce output and accept a cancel. v1.20 implements the Turn read model (Stage D5): the public Turn list and detail read a Session's Turns without their input, newest first by creation time in milliseconds and then Turn ID, with an opaque cursor; a deleted Session's Turns are not readable, and the durable admission fields of a Turn stay planned until D7. v1.21 declares on the task list, detail and event routes of both surfaces the 403 actor_scope_mismatch that the tenant filter answers on every /v1/agents/ and WebShell route, as the Session and Turn reads already do, and the shared Forbidden response names it; a caller that cannot read a task still gets 404. v1.22 adds deployment-opted-in creation with an initial Workspace file-tool Turn (G0) on the public and WebShell surfaces; the fixed server-owned profile does not enable later Workspace operations. v1.23 settles the planned task event and cancel semantics: a durable retention floor, committed-prefix publication, stable cursors, Artifact visibility before expiry, bounded backlog under archival failure, ordered idempotent cancellation and command outcomes. Clients tolerate unknown optional event fields across minor versions. Task events and cancel remain planned. v1.24 serves the read-only Session MCP catalog as partial (Stage H1), projecting display metadata and schemas from committed records without Runtime identities, credentials or production profile enablement. v1.25 implements permission Actions (Stage D6b) on both surfaces: list, get and respond, projected from the Session's action.changed records; a WebShell permission Action carries inputRevision, policyRevision, functionCallId, toolName and expiresAt, its option ids allow and deny are stable, a Turn has at most one requested approval at a time, and a response is a durable command operation carrying requestId. v1.26 declares the tenant filter's 403 actor_scope_mismatch on every covered public and WebShell route, including planned routes, and pins the refusal code in contract traffic. v1.27 admits later Turns of a Workspace-bound Session for the Session's creator under the deployment's Workspace files opt-in, including cancel and rename; Session capabilities advertise the per-caller workspaceTurns flag." + "description": "Canonical contract for the public Managed Agent resources and the WebShell adapter. partial means that the route exists but generated types and full contract tests are pending; planned means that the route or field is not implemented. Workspace context and cwd operations are v1.9 target contracts, not current server capabilities. v1.10 adds planned command admission, Actions, lifecycle and actor authorization. v1.12 refines planned W0 creation discovery, actor-specific create hints and explicit default selection. Full tool-result descriptors from the v1.11 design are not yet included. Filter planned fields as well as routes from production SDKs. v1.13 moves this contract into the Qwen Code repository as its single source and records the shipped rename, archive, unarchive and delete routes as partial. v1.14 integrates W0d workspace discovery, the WebShell workspace lookup, and empty-session binding as partial; workspace_binding/workspaceBinding advertises this narrow flow without enabling workspace execution or context switching. v1.15 adds the archived, archiving and deleting Session statuses and the cancelling Turn status, requires request_id in error envelopes, declares the error responses that the server returns, and marks Session create, list and get on both surfaces implemented. v1.16 adds the planned Stage H task contract (SessionTaskView as PublicTask, task list, detail, events and cancel) and names the MCP catalog, hook catalog, automation and channel resources as planned; their shapes arrive with Stages H1 to H6. v1.17 implements event replay: events carry their schema and projection versions and a top-level Item and Part identity, JSON event pages return has_more and next_cursor and accept limits up to 1000, and a persisted replay floor answers expired cursors with 409 cursor_expired in JSON and one agent.session.resync_required frame over SSE; it marks the public event query and stream, the WebShell event stream and the WebShell transcript implemented. v1.18 implements the durable Session lifecycle (Stage D4): close, archive and delete answer 202 with a command operation on both surfaces, an archive requires a closed Session, a deleted Session leaves a tombstone whose operations stay readable, and the operation query serves them; unarchive restores a closed Session, and Session capabilities advertise session_lifecycle. v1.19 serves the task list and detail on both surfaces as partial (Stage H0c): the Session store projects them from the Stage H records the Session authority commits, a task.updated Session event announces each change of a task's view, and Session capabilities advertise tasks. Task events and cancel stay planned until a slice's tasks produce output and accept a cancel. v1.20 implements the Turn read model (Stage D5): the public Turn list and detail read a Session's Turns without their input, newest first by creation time in milliseconds and then Turn ID, with an opaque cursor; a deleted Session's Turns are not readable, and the durable admission fields of a Turn stay planned until D7. v1.21 declares on the task list, detail and event routes of both surfaces the 403 actor_scope_mismatch that the tenant filter answers on every /v1/agents/ and WebShell route, as the Session and Turn reads already do, and the shared Forbidden response names it; a caller that cannot read a task still gets 404. v1.22 adds deployment-opted-in creation with an initial Workspace file-tool Turn (G0) on the public and WebShell surfaces; the fixed server-owned profile does not enable later Workspace operations. v1.23 settles the planned task event and cancel semantics: a durable retention floor, committed-prefix publication, stable cursors, Artifact visibility before expiry, bounded backlog under archival failure, ordered idempotent cancellation and command outcomes. Clients tolerate unknown optional event fields across minor versions. Task events and cancel remain planned. v1.24 serves the read-only Session MCP catalog as partial (Stage H1), projecting display metadata and schemas from committed records without Runtime identities, credentials or production profile enablement. v1.25 implements permission Actions (Stage D6b) on both surfaces: list, get and respond, projected from the Session's action.changed records; a WebShell permission Action carries inputRevision, policyRevision, functionCallId, toolName and expiresAt, its option ids allow and deny are stable, a Turn has at most one requested approval at a time, and a response is a durable command operation carrying requestId. v1.26 declares the tenant filter's 403 actor_scope_mismatch on every covered public and WebShell route, including planned routes, and pins the refusal code in contract traffic. v1.27 admits later Turns of a Workspace-bound Session for the Session's creator under the deployment's Workspace files opt-in, including cancel and rename; WebShell Session capabilities advertise the per-caller workspaceTurns flag." }, "servers": [ { @@ -159,7 +159,7 @@ "tags": ["Public Sessions"], "operationId": "createSession", "x-qwen-implementation-status": "implemented", - "description": "Workspace selection is persisted with the original actor-scoped creation receipt. With the deployment's G0 file admission opt-in, qwen-code Sessions in an authorized, preregistered Workspace may include initial input under the fixed preapproved file-tool profile. Public callers cannot select the profile. Otherwise Workspace creation must have empty input. Later Workspace submit, cancel and lifecycle operations remain gated. Check replay before resolving defaults; a durable binding is not proof of Runtime readiness.", + "description": "Workspace selection is persisted with the original actor-scoped creation receipt. With the deployment's G0 file admission opt-in, qwen-code Sessions in an authorized, preregistered Workspace may include initial input under the fixed preapproved file-tool profile. Public callers cannot select the profile. Otherwise Workspace creation must have empty input. The Session creator may submit later Turns and cancel its running Turns under the same opt-in, and may rename the Session; close, archive, delete, unarchive and cwd operations remain gated. Check replay before resolving defaults; a durable binding is not proof of Runtime readiness.", "parameters": [ { "$ref": "#/components/parameters/IdempotencyKey" @@ -852,7 +852,7 @@ "tags": ["WebShell"], "operationId": "webShellCreateSession", "x-qwen-implementation-status": "implemented", - "description": "Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. Later Workspace submit, cancel and lifecycle operations remain gated. Freeze selection with the original idempotency key; admission does not prove physical directory readiness.", + "description": "Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns and cancel its running Turns under the same opt-in, and may rename the Session; close, archive, delete, unarchive and cwd operations remain gated. Freeze selection with the original idempotency key; admission does not prove physical directory readiness.", "requestBody": { "required": true, "content": { @@ -3955,7 +3955,7 @@ "workspaceTurns": { "type": "boolean", "default": false, - "description": "True when the caller may submit and cancel later Turns of this Workspace-bound Session: the deployment enables Workspace files and the caller created the Session. False for every other caller and for unbound Sessions, which do not use it." + "description": "True when the caller may submit later Turns of this Workspace-bound Session, cancel its running Turns and rename it: the deployment enables Workspace files, the caller created the Session and currently holds Workspace read and create grants, and the Session is an active qwen-code Session on the frozen execution profile. False for every other caller and for unbound Sessions, which do not use it." }, "tasks": { "type": "boolean" diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java index 4b69908a28f..867a19dbe21 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java @@ -237,6 +237,29 @@ private void runFiles() throws Exception { assertThat(request("PATCH", "/v1/agents/sessions/" + session, rename, "rename-" + workspace, "actor", 200).path("metadata").path("title").asText()).isEqualTo("Renamed " + workspace); + // A creator whose can_create grant is revoked keeps read access but loses + // admission: submit, cancel and rename all answer workspace_unavailable, + // nothing new executes, and no PENDING command row is left behind. + jdbc.update("UPDATE managed_workspace_access SET can_create = FALSE" + + " WHERE tenant_id = ? AND workspace_id = ? AND actor_id = ?", + tenant, workspace, "actor".getBytes(StandardCharsets.UTF_8)); + assertThat(request("POST", "/v1/agents/sessions/" + session + "/events", later, + "nocreate-later-" + workspace, "actor", 409).path("error").path("code").asText()) + .isEqualTo("workspace_unavailable"); + assertThat(request("POST", "/v1/agents/sessions/" + session + "/events", cancel, + "nocreate-cancel-" + workspace, "actor", 409).path("error").path("code").asText()) + .isEqualTo("workspace_unavailable"); + assertThat(request("PATCH", "/v1/agents/sessions/" + session, rename, + "nocreate-rename-" + workspace, "actor", 409).path("error").path("code").asText()) + .isEqualTo("workspace_unavailable"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM qwen_tool_execution WHERE harness_session_id = ?", + Long.class, session)).isEqualTo(executions * 2); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_command" + + " WHERE tenant_id = ? AND command_status = 'PENDING'", Integer.class, tenant)).isZero(); + jdbc.update("UPDATE managed_workspace_access SET can_create = TRUE" + + " WHERE tenant_id = ? AND workspace_id = ? AND actor_id = ?", + tenant, workspace, "actor".getBytes(StandardCharsets.UTF_8)); + // With the running Turn settled, revoking the creator's read grant hides the // bound Session from every later-Turn path: submit, cancel and rename all fall // through to the legacy gate and answer session_not_found. diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java index 5ce1de455f9..c24043963ab 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java @@ -10,11 +10,14 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.alibaba.qwen.code.managedagent.api.ApiException; +import com.alibaba.qwen.code.managedagent.api.ApiModels.InputBlock; import com.alibaba.qwen.code.managedagent.api.AuthenticatedTenantActor; import com.alibaba.qwen.code.managedagent.api.TenantContextFilter; import com.alibaba.qwen.code.managedagent.api.WorkspaceSelection; import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.harness.UnavailableHarnessConnector; import com.alibaba.qwen.code.managedagent.service.ManagedAgentService; +import com.alibaba.qwen.code.managedagent.service.RequestDigests; import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore; import com.alibaba.qwen.code.managedagent.store.ManagedWorkspaceRegistry; import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionMutationKind; @@ -578,8 +581,9 @@ void enabledStoreAdmitsALaterTurnForTheBoundSessionCreator() { + " managed_agent_turn WHERE tenant_id = ?" + " AND session_id = ?", Integer.class, tenant, sessionId)).isEqualTo(1); - // Unarchive stays gated for bound Sessions even under the opt-in; - // the enabled store opens rename only. + // Unarchive stays gated for bound Sessions even under the opt-in. + // It throws before any command row is written, so the rename probe + // after it cannot collide with a leftover PENDING operation. assertThatThrownBy(() -> transaction.execute(status -> gated.beginSessionMutation(tenant, "UNARCHIVE_SESSION", "unarchive-1", digest, sessionId, @@ -587,12 +591,78 @@ void enabledStoreAdmitsALaterTurnForTheBoundSessionCreator() { .isInstanceOfSatisfying(ApiException.class, error -> assertThat(error.getCode()) .isEqualTo("workspace_unavailable")); + // Rename is the one lifecycle-adjacent mutation the enabled store + // opens for a bound Session: it begins PENDING and completes with + // the new title. + var rename = transaction.execute(status -> + gated.beginSessionMutation(tenant, "RENAME_SESSION", + "rename-1", digest, sessionId, + SessionMutationKind.RENAME)); + assertThat(rename.status()).isEqualTo("PENDING"); + assertThat(transaction.execute(status -> + gated.completeSessionMutation(tenant, "RENAME_SESSION", + "rename-1", sessionId, SessionMutationKind.RENAME, + "renamed title", "boot")).title()) + .isEqualTo("renamed title"); // Cancel stays open for a bound Session under the opt-in. assertThat(gated.insertCancelCommand(tenant, "CANCEL", "cancel-1", digest, sessionId, admission.turnId()).turnId()) .isEqualTo(admission.turnId()); } + @Test + void emptyBoundCreationOutsideTheProfileIsNotAdmittedForLaterTurns() { + String tenant = "tenant-" + UUID.randomUUID(); + register(tenant, "ws-a", "storage-a", + WorkspaceExecutionProfile.CONFIG_REF, + WorkspaceExecutionProfile.POLICY_REF); + grant(tenant, "ws-a", "actor-a", true); + String digest = "sha256:" + "a".repeat(64); + // Empty bound creation skips the execution-profile validation by + // design, so a non qwen-code agent_id can be bound; the later-Turn + // admission gate is what must refuse it. + String sessionId = store.insertWorkspaceSessionCommand(tenant, + "actor-a", "create", digest, "another-agent", null, null, + List.of(), null, new WorkspaceSelection("ws-a", ".")) + .sessionId(); + // A Session whose snapshotted profile refs are not the frozen pair + // is refused too, even with the qwen-code agent. + register(tenant, "ws-drift", "storage-drift"); + grant(tenant, "ws-drift", "actor-a", true); + String driftedId = store.insertWorkspaceSessionCommand(tenant, + "actor-a", "create-drift", digest, "qwen-code", null, null, + List.of(), null, new WorkspaceSelection("ws-drift", ".")) + .sessionId(); + String controlId = store.insertWorkspaceSessionCommand(tenant, + "actor-a", "create-control", digest, "qwen-code", null, null, + List.of(), null, new WorkspaceSelection("ws-a", ".")) + .sessionId(); + UnavailableHarnessConnector enabledHarness = + new UnavailableHarnessConnector() { + @Override + public boolean isWorkspaceFilesAvailable() { + return true; + } + }; + ManagedAgentService enabled = new ManagedAgentService(store, + new RequestDigests(), null, enabledHarness, registry); + + assertThat(enabled.getWebShellSession(tenant, "actor-a", sessionId) + .capabilities().workspaceTurns()).isFalse(); + assertThat(enabled.getWebShellSession(tenant, "actor-a", driftedId) + .capabilities().workspaceTurns()).isFalse(); + assertThatThrownBy(() -> enabled.submitTurn(tenant, "actor-a", + "later", sessionId, + List.of(new InputBlock("text", "go")))) + .isInstanceOfSatisfying(ApiException.class, error -> + assertThat(error.getCode()) + .isEqualTo("workspace_unavailable")); + // The same shape on the frozen profile with the qwen-code agent + // stays admitted. + assertThat(enabled.getWebShellSession(tenant, "actor-a", controlId) + .capabilities().workspaceTurns()).isTrue(); + } + @Test void enabledCreationRefusesPolicyDriftAndAnotherTenantsMount() { String tenant = "tenant-" + UUID.randomUUID(); diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java index 5c37a5502c8..76122985c1b 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java @@ -233,7 +233,9 @@ void cancelsABoundTurnThroughTheHarnessWithTheWorkspaceOptIn() { AgentStateStore store = boundCancellingStore(); HarnessConnector harness = mock(HarnessConnector.class); when(harness.isWorkspaceFilesAvailable()).thenReturn(true); - when(harness.createOrLoad("tenant", "session", true)) + // Cancellation attaches passively: an abort must not depend on the + // physical mount still verifying. + when(harness.createOrLoad("tenant", "session", true, true)) .thenReturn(new Attachment("boot", null, null, null)); when(store.bindHarness(eq("tenant"), eq("session"), eq("turn"), anyString(), eq("boot"))).thenReturn(true); @@ -243,6 +245,7 @@ void cancelsABoundTurnThroughTheHarnessWithTheWorkspaceOptIn() { new ManagedAgentProperties()); try { coordinator.cancel("tenant", "session", "turn"); + verify(harness).createOrLoad("tenant", "session", true, true); verify(harness).cancel("tenant", "session"); } finally { coordinator.close(); diff --git a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts index 6dcb864c451..386f7955706 100644 --- a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts +++ b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts @@ -76,7 +76,7 @@ export interface paths { }; get?: never; put?: never; - /** @description Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. Later Workspace submit, cancel and lifecycle operations remain gated. Freeze selection with the original idempotency key; admission does not prove physical directory readiness. */ + /** @description Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns and cancel its running Turns under the same opt-in, and may rename the Session; close, archive, delete, unarchive and cwd operations remain gated. Freeze selection with the original idempotency key; admission does not prove physical directory readiness. */ post: operations["webShellCreateSession"]; delete?: never; options?: never; @@ -408,7 +408,7 @@ export interface components { /** @default false */ actions: boolean; /** - * @description True when the caller may submit and cancel later Turns of this Workspace-bound Session: the deployment enables Workspace files and the caller created the Session. False for every other caller and for unbound Sessions, which do not use it. + * @description True when the caller may submit later Turns of this Workspace-bound Session, cancel its running Turns and rename it: the deployment enables Workspace files, the caller created the Session and currently holds Workspace read and create grants, and the Session is an active qwen-code Session on the frozen execution profile. False for every other caller and for unbound Sessions, which do not use it. * @default false */ workspaceTurns?: boolean; From 66646a6c40d752108b748fbecbc4e317f0d70560 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 1 Oct 2026 17:25:58 +0900 Subject: [PATCH 15/73] fix(sdk-java): cancel a live bound Turn through its running attachment 26de98cd7e made the live cancel attach passively. A passive attach reloads the Session in the Hosted Harness instead of reusing the running Turn's attachment, so the abort never reached the Turn and HostedPublicWorkspaceIT timed out with the Turn still CANCELLING. Restore the ordinary attach for the live path; cancellation recovery, which has no live attachment, keeps attaching passively. Cancelling under a refused Workspace authorization stays a follow-up. --- .../code/managedagent/service/HarnessCoordinator.java | 11 ++++++----- .../managedagent/service/HarnessCoordinatorTest.java | 10 ++++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java index 667540d7696..294cf4fda3e 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java @@ -550,13 +550,14 @@ private void cancelAdmittedTurn(String tenantId, String sessionId, && !harness.isWorkspaceFilesAvailable()) { return; } - // Attach passively, like the cancellation-recovery path above: - // an abort must not depend on the physical mount still - // verifying, or a cancel the API already answered would be - // dropped with only a WARN to show for it. + // A live cancel reuses the running Turn's attachment. A passive + // attach reloads the Session in the Harness, so the abort would + // reach a different attachment and the Turn would stay CANCELLING; + // only cancellation recovery, which has no live attachment, may + // attach passively. Attachment attachment = harness.createOrLoad( session.tenantId(), session.sessionId(), - session.harnessBootId() != null, true); + session.harnessBootId() != null); if (store.bindHarness(tenantId, sessionId, turnId, owner, attachment.bootId())) { harness.cancel(session.tenantId(), session.sessionId()); diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java index 76122985c1b..06de572469b 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java @@ -233,9 +233,7 @@ void cancelsABoundTurnThroughTheHarnessWithTheWorkspaceOptIn() { AgentStateStore store = boundCancellingStore(); HarnessConnector harness = mock(HarnessConnector.class); when(harness.isWorkspaceFilesAvailable()).thenReturn(true); - // Cancellation attaches passively: an abort must not depend on the - // physical mount still verifying. - when(harness.createOrLoad("tenant", "session", true, true)) + when(harness.createOrLoad("tenant", "session", true)) .thenReturn(new Attachment("boot", null, null, null)); when(store.bindHarness(eq("tenant"), eq("session"), eq("turn"), anyString(), eq("boot"))).thenReturn(true); @@ -245,7 +243,11 @@ void cancelsABoundTurnThroughTheHarnessWithTheWorkspaceOptIn() { new ManagedAgentProperties()); try { coordinator.cancel("tenant", "session", "turn"); - verify(harness).createOrLoad("tenant", "session", true, true); + // The live cancel keeps the running attachment; a passive reload + // would leave the abort on a different one. + verify(harness).createOrLoad("tenant", "session", true); + verify(harness, never()).createOrLoad("tenant", "session", true, + true); verify(harness).cancel("tenant", "session"); } finally { coordinator.close(); From 9a60cffa7624b6dc4a285b85b82ad2bec8792f18 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 1 Oct 2026 23:14:57 +0900 Subject: [PATCH 16/73] fix(managed-agent): stop a bound Turn under refused authorization Follow-ups to #13112 from its round-4 verification (#13162). Cancelling aborts work that is already running, so it no longer needs the grants that admit new work: the creator who can still read the Workspace may cancel while can_create is revoked, the Workspace is draining or it was re-registered. A live cancel reuses the running Turn's attachment and runs no Workspace authorization; only without one does it fall back to the existing attach. A cancel the Harness did not take is re-sent with backoff while the Turn is still CANCELLING, since the running dispatcher checks CANCELLING only once. Admission of new work now also requires the Workspace generation and storage the Session was bound to, so a re-registration refuses submit and rename synchronously, before any command row is written. Tests pin the opt-in clause, the creator cancel under revocation and re-registration, the live-attachment path and the resend, and the IT cancels a running Turn after revoking the grant and draining the Workspace. --- ...09-29-hosted-public-workspace-admission.md | 8 ++ ...hosted-public-workspace-admission.zh-CN.md | 2 +- .../sdk-java/managed-agent-server/README.md | 7 +- .../harness/HarnessConnector.java | 11 +++ .../harness/QwenHostedHarnessConnector.java | 13 +++ .../service/HarnessCoordinator.java | 57 +++++++++--- .../service/ManagedAgentService.java | 33 ++++++- .../store/ManagedWorkspaceRegistry.java | 13 +++ .../managed-agent-public-api.openapi.json | 2 +- .../managedagent/HostedPublicWorkspaceIT.java | 31 +++++-- .../ManagedWorkspaceAdmissionTest.java | 93 +++++++++++++++++++ .../service/HarnessCoordinatorTest.java | 82 ++++++++++++++++ .../managed/generated/managed-agent-api.ts | 2 +- 13 files changed, 324 insertions(+), 30 deletions(-) diff --git a/docs/design/2026-09-29-hosted-public-workspace-admission.md b/docs/design/2026-09-29-hosted-public-workspace-admission.md index 598b6269d97..015375cec2c 100644 --- a/docs/design/2026-09-29-hosted-public-workspace-admission.md +++ b/docs/design/2026-09-29-hosted-public-workspace-admission.md @@ -26,6 +26,14 @@ Turn against the creator's Workspace grants, so any other actor, and every deployment without the opt-in, keeps the existing refusal: `workspace_unavailable` when the actor can read the Workspace, `session_not_found` when they cannot. The creator may also rename the Session. +Admitting new work requires the creator's create grant on an `ACTIVE` +Workspace at the generation and storage the Session was bound to, so a +re-registration refuses submit and rename before any command is written. +Cancelling only aborts work already running: the creator who can still read the +Workspace may cancel even after the create grant is revoked, the Workspace +starts draining or it is re-registered. A live cancel reuses the running +Turn's attachment without re-running the execution authority, and a cancel the +Harness did not take is re-sent while the Turn is still cancelling. Close, archive, delete, unarchive and cwd operations remain gated: the Runtime Broker's drain only stops warming a closed Session and has no Harness-level teardown yet. diff --git a/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md b/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md index 1956a0225ac..c11bd17a9cd 100644 --- a/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md +++ b/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md @@ -10,7 +10,7 @@ Hosted Read/Write/Edit 已能经过生产 Broker 和 worker 执行,但目前 G0 开放随创建会话准入的一次初始文件工具轮次,使用现有公开 REST 路由及共享 service 的 WebShell 创建适配器。发现接口仍仅广播 Workspace 绑定能力,不宣称完整的 Workspace 执行支持。G0 本身无需修改 UI;后续改动唯一的 UI 变化是为创建者开放输入框与取消控件。 -后续改动在同一开关下为会话创建者开放后续 Turn,并允许创建者取消正在运行的 Turn,由 Hosted Harness 中止该 Turn 并按原始 Runtime 身份结算。执行时每个 Turn 都按创建者的 Workspace 授权校验,因此其他 actor 以及未开启该开关的部署仍得到现有拒绝:actor 可读取该 Workspace 时为 `workspace_unavailable`,不能读取时为 `session_not_found`。创建者也可以重命名该会话。关闭、归档、删除、取消归档与 cwd 操作仍保持门禁:Runtime Broker 的 drain 只是停止为已关闭会话预热,尚无 Harness 级别的回收。 +后续改动在同一开关下为会话创建者开放后续 Turn,并允许创建者取消正在运行的 Turn,由 Hosted Harness 中止该 Turn 并按原始 Runtime 身份结算。执行时每个 Turn 都按创建者的 Workspace 授权校验,因此其他 actor 以及未开启该开关的部署仍得到现有拒绝:actor 可读取该 Workspace 时为 `workspace_unavailable`,不能读取时为 `session_not_found`。创建者也可以重命名该会话。准入新工作要求创建者在 `ACTIVE` 的 Workspace 上持有创建授权,且 Workspace 的 generation 与存储与会话绑定时一致,因此重新注册后提交和改名会在写入任何命令之前被拒绝。取消只中止已在运行的工作:仍能读取该 Workspace 的创建者,即使创建授权被撤销、Workspace 进入 drain 或被重新注册,也可以取消。正常取消复用运行中 Turn 的挂接,不再执行执行授权;Harness 未接收的取消会在 Turn 仍处于取消中时重发。关闭、归档、删除、取消归档与 cwd 操作仍保持门禁:Runtime Broker 的 drain 只是停止为已关闭会话预热,尚无 Harness 级别的回收。 ## 决策 diff --git a/packages/sdk-java/managed-agent-server/README.md b/packages/sdk-java/managed-agent-server/README.md index 1a8069a4210..0638d983105 100644 --- a/packages/sdk-java/managed-agent-server/README.md +++ b/packages/sdk-java/managed-agent-server/README.md @@ -473,8 +473,11 @@ does not provide physical isolation or recovery after worker-only death. Public bound Turn admission is limited to the opt-in initial file Turn described in G0 above and to later Turns submitted by the Session's creator under the same opt-in while they can still read the Workspace (the per-caller `workspaceTurns` -capability flag reflects this); the creator may also cancel the Session's -running Turns and rename the Session. Later Turns run +capability flag reflects this); the creator may also rename the Session. +Cancelling aborts work that is already running, so the creator may cancel a +running Turn while they can still read the Workspace, even after their create +grant is revoked, the Workspace starts draining or it is re-registered. Later +Turns run under the creator's Workspace grants, so any other actor keeps the existing refusal: `workspace_unavailable` when the actor can read the Workspace, `session_not_found` when they cannot. Public close, archive, delete and diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessConnector.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessConnector.java index 46dd315c258..4fa873168ec 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessConnector.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessConnector.java @@ -3,6 +3,7 @@ import com.alibaba.qwen.code.daemon.HarnessRuntimeRecovery; import java.util.List; import java.util.Map; +import java.util.Optional; import com.fasterxml.jackson.databind.JsonNode; public interface HarnessConnector extends AutoCloseable { @@ -20,6 +21,16 @@ default Attachment createOrLoad(String tenantId, String sessionId, return createOrLoad(tenantId, sessionId, loadExisting); } + /** + * The attachment this connector already holds for the Session, if any. + * Reusing it runs no Workspace authorization, so aborting running work + * does not depend on the grants that admit new work. + */ + default Optional liveAttachment(String tenantId, + String sessionId) { + return Optional.empty(); + } + Admission submit(String tenantId, String sessionId, String promptId, List> input, String payloadDigest); diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java index 25c91c2bb6c..bb3e7b75e5d 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java @@ -22,6 +22,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import com.fasterxml.jackson.databind.JsonNode; import com.alibaba.qwen.code.managedagent.store.ManagedActionStore; @@ -217,6 +218,18 @@ public void resolveAction( response.path("policyRevision").asText()); } + @Override + public Optional liveAttachment(String tenantId, + String sessionId) { + HarnessSessionRef attached = attachments.get( + new AttachmentKey(tenantId, sessionId)); + return attached == null ? Optional.empty() + : Optional.of(new Attachment(attached.getHarnessBootId(), + attached.getRuntimeRecovery(), + attached.getHarnessLastEventId(), + attached.getHarnessEventEpoch())); + } + @Override public void cancel(String tenantId, String sessionId) { client().cancelTurn(attachment(tenantId, sessionId, false)); diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java index 294cf4fda3e..5fa7f85cfcf 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java @@ -33,6 +33,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -61,6 +62,13 @@ public class HarnessCoordinator { private final int batchMaxEvents; private final int batchMaxBytes; private final String owner = UUID.randomUUID().toString(); + // A cancel the Harness did not take is re-sent with these delays (the + // last one repeating) while its Turn is still CANCELLING. The running + // dispatcher checks CANCELLING only once, before it starts streaming, so + // nothing else re-sends it. + private static final long[] CANCEL_RETRY_MILLIS = {1_000, 2_000, 5_000, + 10_000}; + private static final int CANCEL_RETRY_LIMIT = 60; private final Set active = ConcurrentHashMap.newKeySet(); private final ScheduledExecutorService renewer = Executors.newSingleThreadScheduledExecutor(runnable -> { @@ -121,7 +129,7 @@ public void dispatch(String tenantId, String sessionId, String turnId) { public void cancel(String tenantId, String sessionId, String turnId) { dispatch(tenantId, sessionId, turnId); executor.execute(() -> cancelAdmittedTurn(tenantId, sessionId, - turnId)); + turnId, 0)); } @Scheduled(fixedDelayString = @@ -532,7 +540,7 @@ private void runtimeWarmResult(SessionRecord session, TurnRecord turn, } private void cancelAdmittedTurn(String tenantId, String sessionId, - String turnId) { + String turnId, int attempt) { try { TurnRecord turn = store.findTurn(tenantId, sessionId, turnId) .orElse(null); @@ -550,23 +558,46 @@ private void cancelAdmittedTurn(String tenantId, String sessionId, && !harness.isWorkspaceFilesAvailable()) { return; } - // A live cancel reuses the running Turn's attachment. A passive - // attach reloads the Session in the Harness, so the abort would - // reach a different attachment and the Turn would stay CANCELLING; - // only cancellation recovery, which has no live attachment, may - // attach passively. - Attachment attachment = harness.createOrLoad( - session.tenantId(), session.sessionId(), - session.harnessBootId() != null); + // A live cancel reuses the running Turn's attachment, which runs + // no Workspace authorization: aborting running work must not + // depend on the grants that admit new work. A passive attach + // would reload the Session in the Harness, so the abort would + // reach a different attachment and the Turn would stay + // CANCELLING; only cancellation recovery, which has no live + // attachment, may attach passively. + Attachment attachment = harness.liveAttachment( + session.tenantId(), session.sessionId()) + .orElseGet(() -> harness.createOrLoad(session.tenantId(), + session.sessionId(), + session.harnessBootId() != null)); if (store.bindHarness(tenantId, sessionId, turnId, owner, attachment.bootId())) { harness.cancel(session.tenantId(), session.sessionId()); } } catch (RuntimeException error) { - LOG.warn("Managed Turn cancellation will recover tenant={}" - + " session={} turn={} failure={}", - tenantId, sessionId, turnId, + LOG.warn("Managed Turn cancellation will retry tenant={}" + + " session={} turn={} attempt={} failure={}", + tenantId, sessionId, turnId, attempt, error.getClass().getSimpleName()); + retryCancellation(tenantId, sessionId, turnId, attempt + 1); + } + } + + private void retryCancellation(String tenantId, String sessionId, + String turnId, int attempt) { + if (attempt > CANCEL_RETRY_LIMIT) { + LOG.warn("Managed Turn cancellation stopped retrying tenant={}" + + " session={} turn={}", tenantId, sessionId, turnId); + return; + } + long delay = CANCEL_RETRY_MILLIS[Math.min(attempt, + CANCEL_RETRY_MILLIS.length) - 1]; + try { + renewer.schedule(() -> executor.execute(() -> cancelAdmittedTurn( + tenantId, sessionId, turnId, attempt)), delay, + TimeUnit.MILLISECONDS); + } catch (RejectedExecutionException closed) { + // The coordinator is shutting down; recovery takes over. } } diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java index c27b21663f1..5e55fd28b0a 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java @@ -236,7 +236,7 @@ public CommandAdmission submitTurn(String tenantId, String actorId, public CommandAdmission cancelTurn(String tenantId, String actorId, String idempotencyKey, String sessionId, String turnId) { validateIdempotencyKey(idempotencyKey); - requireSubmitter(tenantId, actorId, sessionId); + requireCanceller(tenantId, actorId, sessionId); String requestDigest = digests.digest(Map.of( "sessionId", sessionId, "turnId", turnId)); Admission replay = replay(tenantId, CANCEL, idempotencyKey, @@ -700,8 +700,9 @@ String lifecycleDigest(String sessionId, String operation) { // Later Turns of a Workspace-bound Session run under the creator's // Workspace grants (WorkspaceExecutionStore.authorize), so only the - // creator may submit or cancel them or rename the Session, and only with - // Workspace files enabled. Everyone else keeps the existing refusal. + // creator may submit them or rename the Session, and only with Workspace + // files enabled. Everyone else keeps the existing refusal. Cancelling + // has its own, narrower rule (requireCanceller). private void requireSubmitter(String tenantId, String actorId, String sessionId) { SessionRecord session = store.requireSession(tenantId, sessionId); @@ -715,6 +716,23 @@ private boolean maySubmitWorkspaceTurn(SessionRecord session, return maySubmitWorkspaceTurn(session, actorId, false); } + // Cancelling aborts work that is already running, so it needs only what + // identifies the creator, not the grants that admit new work: the + // creator who can still read the Workspace may cancel while can_create is + // revoked, the Workspace is draining or it was re-registered. + private void requireCanceller(String tenantId, String actorId, + String sessionId) { + SessionRecord session = store.requireSession(tenantId, sessionId); + if (session.workspace() == null + || !harness.isWorkspaceFilesAvailable() + || !workspaces.canRead(session.tenantId(), actorId, + session.workspace().getWorkspaceId()) + || !workspaces.createdSession(session.tenantId(), actorId, + session.sessionId())) { + requireLegacyWorkspace(session, actorId); + } + } + // readGranted is true on the read paths (session get/list), where the // page query or requireReadGrant already established the caller's // can_read for a bound row, so the clause would re-ask a fixed true. @@ -745,7 +763,14 @@ private boolean maySubmitWorkspaceTurn(SessionRecord session, ManagedWorkspaceRegistry.WorkspaceSummary summary = workspaces.findReadable(session.tenantId(), actorId, session.workspace().getWorkspaceId()); - return summary != null && summary.canCreateSession(); + // Execution also requires the Workspace generation and storage the + // Session was bound to; after a re-registration it refuses, so + // admission must refuse first instead of accepting a Turn that fails. + return summary != null && summary.canCreateSession() + && workspaces.bindingCurrent(session.tenantId(), + session.workspace().getWorkspaceId(), + session.workspace().getWorkspaceGeneration(), + session.workspace().getStorageId()); } void requireLegacyWorkspace(String tenantId, String actorId, diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedWorkspaceRegistry.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedWorkspaceRegistry.java index 1893192c2f8..5665f2ffa92 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedWorkspaceRegistry.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedWorkspaceRegistry.java @@ -145,6 +145,19 @@ private static WorkspaceSummary summary(ResultSet result) && "ACTIVE".equals(state)); } + /** + * Whether the registry still holds the Workspace generation and storage a + * Session was bound to; a re-registration changes them. + */ + public boolean bindingCurrent(String tenantId, String workspaceId, + long generation, String storageId) { + return !jdbc.queryForList("SELECT 1 FROM managed_workspace_registry" + + " WHERE tenant_id = ? AND workspace_id = ?" + + " AND workspace_generation = ? AND storage_id = ?", + Integer.class, tenantId, workspaceId, generation, storageId) + .isEmpty(); + } + public record WorkspaceSummary(String workspaceId, String displayName, String state, boolean canCreateSession) { } diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json index e0b168fdc01..182ba8603e3 100644 --- a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json +++ b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json @@ -4636,7 +4636,7 @@ "workspaceTurns": { "type": "boolean", "default": false, - "description": "True when the caller may submit later Turns of this Workspace-bound Session, cancel its running Turns and rename it: the deployment enables Workspace files, the caller created the Session and currently holds Workspace read and create grants, and the Session is an active qwen-code Session on the frozen execution profile. False for every other caller and for unbound Sessions, which do not use it." + "description": "True when the caller may submit later Turns of this Workspace-bound Session and rename it: the deployment enables Workspace files, the caller created the Session and currently holds Workspace read and create grants on the Workspace generation the Session was bound to, and the Session is an active qwen-code Session on the frozen execution profile. Cancelling only aborts work that is already running, so the creator may cancel a running Turn even when this is false, as long as they can still read the Workspace. False for every other caller and for unbound Sessions, which do not use it." }, "tasks": { "type": "boolean" diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java index 867a19dbe21..eebe5f38abd 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java @@ -237,18 +237,31 @@ private void runFiles() throws Exception { assertThat(request("PATCH", "/v1/agents/sessions/" + session, rename, "rename-" + workspace, "actor", 200).path("metadata").path("title").asText()).isEqualTo("Renamed " + workspace); - // A creator whose can_create grant is revoked keeps read access but loses - // admission: submit, cancel and rename all answer workspace_unavailable, - // nothing new executes, and no PENDING command row is left behind. + // A creator whose can_create grant is revoked, with the Workspace draining, keeps + // read access but loses admission of new work: submit and rename answer + // workspace_unavailable, and no PENDING command row is left behind. Cancelling + // only aborts work already running, so a Turn started before the revocation is + // still cancelled and stops without running another tool. + int beforeRevokedCancel = modelRequests.size(); + String revokedTurn = request("POST", "/v1/agents/sessions/" + session + "/events", hold, + "hold-revoked-" + workspace, "actor", 202).path("turn_id").asText(); + await().atMost(Duration.ofSeconds(35)).until(() -> modelRequests.size() > beforeRevokedCancel); jdbc.update("UPDATE managed_workspace_access SET can_create = FALSE" + " WHERE tenant_id = ? AND workspace_id = ? AND actor_id = ?", tenant, workspace, "actor".getBytes(StandardCharsets.UTF_8)); + jdbc.update("UPDATE managed_workspace_registry SET state = 'DRAINING'" + + " WHERE tenant_id = ? AND workspace_id = ?", tenant, workspace); + Map revokedCancel = Map.of("type", "agent.session.cancel", "turn_id", revokedTurn); + request("POST", "/v1/agents/sessions/" + session + "/events", revokedCancel, + "nocreate-cancel-" + workspace, "actor", 202); + await().atMost(Duration.ofSeconds(35)).untilAsserted(() -> assertThat(jdbc.queryForObject( + "SELECT status FROM managed_agent_turn WHERE session_id = ? AND turn_id = ?", + String.class, session, revokedTurn)).isEqualTo("CANCELLED")); + heldReply.countDown(); + heldReply = new CountDownLatch(1); assertThat(request("POST", "/v1/agents/sessions/" + session + "/events", later, "nocreate-later-" + workspace, "actor", 409).path("error").path("code").asText()) .isEqualTo("workspace_unavailable"); - assertThat(request("POST", "/v1/agents/sessions/" + session + "/events", cancel, - "nocreate-cancel-" + workspace, "actor", 409).path("error").path("code").asText()) - .isEqualTo("workspace_unavailable"); assertThat(request("PATCH", "/v1/agents/sessions/" + session, rename, "nocreate-rename-" + workspace, "actor", 409).path("error").path("code").asText()) .isEqualTo("workspace_unavailable"); @@ -259,6 +272,8 @@ private void runFiles() throws Exception { jdbc.update("UPDATE managed_workspace_access SET can_create = TRUE" + " WHERE tenant_id = ? AND workspace_id = ? AND actor_id = ?", tenant, workspace, "actor".getBytes(StandardCharsets.UTF_8)); + jdbc.update("UPDATE managed_workspace_registry SET state = 'ACTIVE'" + + " WHERE tenant_id = ? AND workspace_id = ?", tenant, workspace); // With the running Turn settled, revoking the creator's read grant hides the // bound Session from every later-Turn path: submit, cancel and rename all fall @@ -279,7 +294,7 @@ private void runFiles() throws Exception { + " WHERE tenant_id = ? AND workspace_id = ? AND actor_id = ?", tenant, workspace, "actor".getBytes(StandardCharsets.UTF_8)); } - assertThat(modelRequests).hasSize(approvals ? 16 : 18); + assertThat(modelRequests).hasSize(approvals ? 16 : 20); assertThat(modelFailure.get()).isNull(); Map denied = Map.of("agent_id", "qwen-code", "workspace", Map.of("workspace_id", "workspace-0"), "input", List.of(Map.of("type", "input_text", "text", "G0_FILES"))); @@ -312,7 +327,7 @@ private void runFiles() throws Exception { assertUnavailable(request("POST", "/v1/agents/sessions", denied, "unsupported", "actor", 409)); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_session WHERE tenant_id = ?", Integer.class, tenant)).isEqualTo(2); - assertThat(modelRequests).hasSize(approvals ? 16 : 18); + assertThat(modelRequests).hasSize(approvals ? 16 : 20); } private void startSpring(Path cli, List roots, int harnessPort, int brokerPort) { diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java index c24043963ab..409e4c2c222 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java @@ -31,6 +31,7 @@ import java.util.concurrent.CyclicBarrier; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -924,6 +925,98 @@ void rejectsMissingGrantsCreateDenialAndRemovedWorkspace() { + " WHERE tenant_id = ?", Integer.class, tenant)).isZero(); } + @Test + void creatorCancelsWithoutTheGrantsThatAdmitNewWork() { + String tenant = "tenant-" + UUID.randomUUID(); + String sessionId = boundSession(tenant); + grant(tenant, "ws-a", "actor-b", false); + ManagedAgentService enabled = boundService(true); + ManagedAgentService optedOut = boundService(false); + assertThat(enabled.getWebShellSession(tenant, "actor-a", sessionId) + .capabilities().workspaceTurns()).isTrue(); + // The opt-in clause: the same creator and Session without it. + assertThat(optedOut.getWebShellSession(tenant, "actor-a", sessionId) + .capabilities().workspaceTurns()).isFalse(); + + jdbc.update("UPDATE managed_workspace_access SET can_create = FALSE" + + " WHERE tenant_id = ?", tenant); + jdbc.update("UPDATE managed_workspace_registry SET state = 'DRAINING'" + + " WHERE tenant_id = ?", tenant); + assertThat(enabled.getWebShellSession(tenant, "actor-a", sessionId) + .capabilities().workspaceTurns()).isFalse(); + assertRefused(() -> enabled.submitTurn(tenant, "actor-a", "submit", + sessionId, List.of(new InputBlock("text", "go"))), + "workspace_unavailable"); + // Cancel admission passes for the creator; the refusal comes from the + // missing Turn, after admission. + assertRefused(() -> enabled.cancelTurn(tenant, "actor-a", "cancel", + sessionId, "turn_missing"), "turn_not_found"); + // A reader who did not create the Session keeps the refusal, and so + // does the creator without the opt-in. + assertRefused(() -> enabled.cancelTurn(tenant, "actor-b", "cancel-b", + sessionId, "turn_missing"), "workspace_unavailable"); + assertRefused(() -> optedOut.cancelTurn(tenant, "actor-a", + "cancel-off", sessionId, "turn_missing"), + "workspace_unavailable"); + } + + @Test + void reRegistrationRefusesLaterWorkBeforeAnyCommandIsWritten() { + String tenant = "tenant-" + UUID.randomUUID(); + String sessionId = boundSession(tenant); + ManagedAgentService enabled = boundService(true); + jdbc.update("UPDATE managed_workspace_registry SET" + + " workspace_generation = workspace_generation + 1" + + " WHERE tenant_id = ?", tenant); + + assertThat(enabled.getWebShellSession(tenant, "actor-a", sessionId) + .capabilities().workspaceTurns()).isFalse(); + assertRefused(() -> enabled.submitTurn(tenant, "actor-a", "submit", + sessionId, List.of(new InputBlock("text", "go"))), + "workspace_unavailable"); + assertRefused(() -> enabled.renameSession(tenant, "actor-a", + "rename", sessionId, "Renamed"), "workspace_unavailable"); + // The rename was refused before its command was written, so no + // PENDING command blocks a later operation. + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM" + + " managed_agent_command WHERE tenant_id = ? AND" + + " command_status = 'PENDING'", Integer.class, tenant)) + .isZero(); + assertRefused(() -> enabled.cancelTurn(tenant, "actor-a", "cancel", + sessionId, "turn_missing"), "turn_not_found"); + } + + private String boundSession(String tenant) { + register(tenant, "ws-a", "storage-a", + WorkspaceExecutionProfile.CONFIG_REF, + WorkspaceExecutionProfile.POLICY_REF); + grant(tenant, "ws-a", "actor-a", true); + return store.insertWorkspaceSessionCommand(tenant, "actor-a", + "create", "sha256:" + "a".repeat(64), "qwen-code", null, null, + List.of(), null, new WorkspaceSelection("ws-a", ".")) + .sessionId(); + } + + private ManagedAgentService boundService(boolean workspaceFiles) { + ManagedAgentProperties properties = new ManagedAgentProperties(); + properties.getHarness().setWorkspaceFilesEnabled(workspaceFiles); + ManagedAgentStore gated = new ManagedAgentStore(jdbc, mapper, + Clock.systemUTC(), ignored -> { + }, registry, properties); + return new ManagedAgentService(gated, new RequestDigests(), null, + new UnavailableHarnessConnector() { + @Override + public boolean isWorkspaceFilesAvailable() { + return workspaceFiles; + } + }, registry); + } + + private static void assertRefused(ThrowingCallable call, String code) { + assertThatThrownBy(call).isInstanceOfSatisfying(ApiException.class, + error -> assertThat(error.getCode()).isEqualTo(code)); + } + private void assertCreateError(String tenant, WorkspaceSelection selection, String expected) { assertThatThrownBy(() -> store.insertWorkspaceSessionCommand(tenant, diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java index 06de572469b..6f5eeb8b4a0 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java @@ -1,14 +1,18 @@ package com.alibaba.qwen.code.managedagent.service; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.after; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions; @@ -254,6 +258,84 @@ void cancelsABoundTurnThroughTheHarnessWithTheWorkspaceOptIn() { } } + @Test + void cancelsThroughTheLiveAttachmentWithoutReauthorizing() { + AgentStateStore store = boundCancellingStore(); + HarnessConnector harness = mock(HarnessConnector.class); + when(harness.isWorkspaceFilesAvailable()).thenReturn(true); + when(harness.liveAttachment("tenant", "session")).thenReturn( + Optional.of(new Attachment("boot", null, null, null))); + when(store.bindHarness(eq("tenant"), eq("session"), eq("turn"), + anyString(), eq("boot"))).thenReturn(true); + HarnessCoordinator coordinator = new HarnessCoordinator(store, harness, + new HarnessEventProjector(), mock(RuntimeWarmer.class), + directExecutor(), Clock.systemUTC(), + new ManagedAgentProperties()); + try { + coordinator.cancel("tenant", "session", "turn"); + // The live attachment runs no Workspace authorization, so a + // revoked grant or a draining Workspace cannot stop the abort. + verify(harness, never()).createOrLoad(anyString(), anyString(), + anyBoolean()); + verify(harness, never()).createOrLoad(anyString(), anyString(), + anyBoolean(), anyBoolean()); + verify(harness).cancel("tenant", "session"); + } finally { + coordinator.close(); + } + } + + @Test + void resendsACancelTheHarnessDidNotTake() { + AgentStateStore store = boundCancellingStore(); + HarnessConnector harness = mock(HarnessConnector.class); + when(harness.isWorkspaceFilesAvailable()).thenReturn(true); + when(harness.createOrLoad("tenant", "session", true)) + .thenThrow(new IllegalStateException("refused")) + .thenReturn(new Attachment("boot", null, null, null)); + when(store.bindHarness(eq("tenant"), eq("session"), eq("turn"), + anyString(), eq("boot"))).thenReturn(true); + HarnessCoordinator coordinator = new HarnessCoordinator(store, harness, + new HarnessEventProjector(), mock(RuntimeWarmer.class), + directExecutor(), Clock.systemUTC(), + new ManagedAgentProperties()); + try { + coordinator.cancel("tenant", "session", "turn"); + // The running dispatcher checks CANCELLING only once, so the + // retry is what delivers the cancel after the first failure. + verify(harness, timeout(5_000)).cancel("tenant", "session"); + verify(harness, times(2)).createOrLoad("tenant", "session", true); + } finally { + coordinator.close(); + } + } + + @Test + void stopsResendingOnceTheTurnIsNoLongerCancelling() { + AgentStateStore store = boundCancellingStore(); + when(store.findTurn("tenant", "session", "turn")).thenReturn( + Optional.of(turn("tenant", "session", "turn", "prompt", + "epoch", 1, "CANCELLING")), + Optional.of(turn("tenant", "session", "turn", "prompt", + "epoch", 1, "COMPLETED"))); + HarnessConnector harness = mock(HarnessConnector.class); + when(harness.isWorkspaceFilesAvailable()).thenReturn(true); + when(harness.createOrLoad("tenant", "session", true)) + .thenThrow(new IllegalStateException("refused")); + HarnessCoordinator coordinator = new HarnessCoordinator(store, harness, + new HarnessEventProjector(), mock(RuntimeWarmer.class), + directExecutor(), Clock.systemUTC(), + new ManagedAgentProperties()); + try { + coordinator.cancel("tenant", "session", "turn"); + verify(harness, after(2_500).times(1)).createOrLoad("tenant", + "session", true); + verify(harness, never()).cancel("tenant", "session"); + } finally { + coordinator.close(); + } + } + private static AgentStateStore boundCancellingStore() { AgentStateStore store = mock(AgentStateStore.class); when(store.findTurn("tenant", "session", "turn")).thenReturn(Optional.of( diff --git a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts index 2a830cc45b6..1817064d438 100644 --- a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts +++ b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts @@ -477,7 +477,7 @@ export interface components { /** @default false */ actions: boolean; /** - * @description True when the caller may submit later Turns of this Workspace-bound Session, cancel its running Turns and rename it: the deployment enables Workspace files, the caller created the Session and currently holds Workspace read and create grants, and the Session is an active qwen-code Session on the frozen execution profile. False for every other caller and for unbound Sessions, which do not use it. + * @description True when the caller may submit later Turns of this Workspace-bound Session and rename it: the deployment enables Workspace files, the caller created the Session and currently holds Workspace read and create grants on the Workspace generation the Session was bound to, and the Session is an active qwen-code Session on the frozen execution profile. Cancelling only aborts work that is already running, so the creator may cancel a running Turn even when this is false, as long as they can still read the Workspace. False for every other caller and for unbound Sessions, which do not use it. * @default false */ workspaceTurns?: boolean; From c80943970624e1539fc479ea587941f1884fd461 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 1 Oct 2026 22:32:02 +0800 Subject: [PATCH 17/73] fix(managed-agent): fail the Turn when a cancel attach is refused The bound-Session cancel path attaches through the strict 3-arg createOrLoad. When the Workspace authority, or the storage guard behind it, refused that attach, the RuntimeException fell into the catch that promises recovery: no harness.cancel was issued for a cancel the API had already answered 202 for, no sweeper re-claims a Turn whose lease the live consumer keeps renewing, and the Turn ran on and settled COMPLETED. Settle the Turn with the refusal's own code instead of dropping the cancel silently. Addresses review thread R2-1 (PRRT_kwDOPB-92c6n-tVW). Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmupkgkl21b --- .../service/HarnessCoordinator.java | 30 +++++++++++++++++-- .../service/HarnessCoordinatorTest.java | 25 ++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java index 294cf4fda3e..336e918b39d 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java @@ -555,9 +555,25 @@ private void cancelAdmittedTurn(String tenantId, String sessionId, // reach a different attachment and the Turn would stay CANCELLING; // only cancellation recovery, which has no live attachment, may // attach passively. - Attachment attachment = harness.createOrLoad( - session.tenantId(), session.sessionId(), - session.harnessBootId() != null); + Attachment attachment; + try { + attachment = harness.createOrLoad( + session.tenantId(), session.sessionId(), + session.harnessBootId() != null); + } catch (RuntimeException refusal) { + // A refused attach can never reach the running Turn, and no + // sweeper re-claims a Turn whose lease the live consumer keeps + // renewing: settle the Turn the API already answered 202 for + // instead of dropping the cancel, which would leave it running + // and then settling COMPLETED. + LOG.warn("Managed Turn cancellation was refused tenant={}" + + " session={} turn={} failure={}", + tenantId, sessionId, turnId, + refusal.getClass().getSimpleName()); + fail(turn, cancelRefusalCode(refusal), + "The Hosted Harness refused the Turn cancellation."); + return; + } if (store.bindHarness(tenantId, sessionId, turnId, owner, attachment.bootId())) { harness.cancel(session.tenantId(), session.sessionId()); @@ -582,6 +598,14 @@ private boolean fail(TurnRecord turn, String code, String message) { return true; } + private static String cancelRefusalCode(RuntimeException refusal) { + if (refusal instanceof RuntimeBrokerException broker + && broker.getCode() != null) { + return broker.getCode(); + } + return "hosted_harness_unavailable"; + } + private boolean transientFailure(TurnRecord turn, boolean submissionAttempted, RuntimeException error) { if (!submissionAttempted diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java index 06de572469b..f66fccbbef3 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java @@ -254,6 +254,31 @@ void cancelsABoundTurnThroughTheHarnessWithTheWorkspaceOptIn() { } } + @Test + void failsTheTurnWhenTheCancelAttachIsRefused() { + AgentStateStore store = boundCancellingStore(); + HarnessConnector harness = mock(HarnessConnector.class); + when(harness.isWorkspaceFilesAvailable()).thenReturn(true); + when(harness.createOrLoad("tenant", "session", true)) + .thenThrow(WorkspaceExecutionStore.unavailable()); + HarnessCoordinator coordinator = new HarnessCoordinator(store, harness, + new HarnessEventProjector(), mock(RuntimeWarmer.class), + directExecutor(), Clock.systemUTC(), + new ManagedAgentProperties()); + try { + coordinator.cancel("tenant", "session", "turn"); + // The abort never reached the Harness, so the cancel the API + // already answered must not be a silent no-op that leaves the + // Turn running and then settling COMPLETED. + verify(harness, never()).cancel(anyString(), anyString()); + verify(store).failTurn(eq("tenant"), eq("session"), eq("turn"), + anyString(), eq("workspace_unavailable"), + anyString()); + } finally { + coordinator.close(); + } + } + private static AgentStateStore boundCancellingStore() { AgentStateStore store = mock(AgentStateStore.class); when(store.findTurn("tenant", "session", "turn")).thenReturn(Optional.of( From 2431e5e670473423d56373e9a97ecd8e1edfb4f0 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 1 Oct 2026 22:32:11 +0800 Subject: [PATCH 18/73] fix(managed-agent): retire a refused rename command instead of wedging rename The permanent-refusal branch in renameSession rethrew a 4xx after beginSessionMutation had already written a PENDING RENAME_SESSION row. Nothing retires that row, so every later rename with a fresh key died in requireNoOpenOperation with session_operation_active for the Session's life. abandonSessionMutation deletes the still-PENDING row before the refusal is answered, so the Session accepts the next rename again. Addresses review thread R3-1 (PRRT_kwDOPB-92c6n-tVl). Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmupkgkl21b --- .../service/ManagedAgentService.java | 6 ++ .../managedagent/store/AgentStateStore.java | 9 +++ .../managedagent/store/ManagedAgentStore.java | 12 +++ .../ManagedWorkspaceAdmissionTest.java | 78 +++++++++++++++++++ 4 files changed, 105 insertions(+) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java index c27b21663f1..0c2f6e22ffb 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java @@ -289,6 +289,12 @@ public SessionMutationResult renameSession( // command. if (error instanceof RuntimeBrokerException refusal && !refusal.isRetryable()) { + // Retire the command row this refusal would leave + // PENDING: nothing else clears it, so every later rename + // with a fresh key would die in + // requireNoOpenOperation for the Session's life. + store.abandonSessionMutation(tenantId, RENAME, + idempotencyKey, sessionId); HttpStatus status = HttpStatus.resolve( refusal.getStatusCode()); throw new ApiException( diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/AgentStateStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/AgentStateStore.java index 343b47a462e..2b189e1feb0 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/AgentStateStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/AgentStateStore.java @@ -58,6 +58,15 @@ SessionRecord completeSessionMutation(String tenantId, String operation, String idempotencyKey, String sessionId, SessionMutationKind kind, String title, String harnessBootId); + /** + * Retires the command row of a Session mutation the Harness refused + * before it could apply it, so the refusal does not leave the Session's + * later lifecycle changes blocked by a {@code PENDING} row nothing + * completes. The idempotency key stays free to re-attempt the mutation. + */ + void abandonSessionMutation(String tenantId, String operation, + String idempotencyKey, String sessionId); + /** * Admits a close, archive or delete, or returns the operation that the * same actor already admitted under the key. An archive completes here; diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java index e7c2c8e3aa5..ecaaf6a54a9 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java @@ -578,6 +578,18 @@ public SessionRecord completeSessionMutation(String tenantId, return requireSessionForUpdate(tenantId, sessionId); } + @Override + @Transactional + public void abandonSessionMutation(String tenantId, String operation, + String idempotencyKey, String sessionId) { + // Only a still-PENDING row is retired: a completed mutation is the + // recorded outcome and must stay replayable. + jdbc.update("DELETE FROM managed_agent_command WHERE tenant_id = ?" + + " AND operation = ? AND idempotency_key = ?" + + " AND session_id = ? AND command_status = 'PENDING'", + tenantId, operation, idempotencyKey, sessionId); + } + @Override @Transactional public OperationAdmission beginOperation(String tenantId, diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java index c24043963ab..0f1a7e8dbca 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java @@ -15,12 +15,14 @@ import com.alibaba.qwen.code.managedagent.api.TenantContextFilter; import com.alibaba.qwen.code.managedagent.api.WorkspaceSelection; import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector.Attachment; import com.alibaba.qwen.code.managedagent.harness.UnavailableHarnessConnector; import com.alibaba.qwen.code.managedagent.service.ManagedAgentService; import com.alibaba.qwen.code.managedagent.service.RequestDigests; import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore; import com.alibaba.qwen.code.managedagent.store.ManagedWorkspaceRegistry; import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionMutationKind; +import com.alibaba.qwen.code.managedagent.store.WorkspaceExecutionStore; import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding; import com.fasterxml.jackson.databind.ObjectMapper; @@ -663,6 +665,82 @@ public boolean isWorkspaceFilesAvailable() { .capabilities().workspaceTurns()).isTrue(); } + @Test + void refusedRenameRetiresItsCommandAndAdmitsTheNextOne() { + String tenant = "tenant-" + UUID.randomUUID(); + register(tenant, "ws-a", "storage-a", + WorkspaceExecutionProfile.CONFIG_REF, + WorkspaceExecutionProfile.POLICY_REF); + grant(tenant, "ws-a", "actor-a", true); + String digest = "sha256:" + "a".repeat(64); + String sessionId = store.insertWorkspaceSessionCommand(tenant, + "actor-a", "create", digest, "qwen-code", null, null, + List.of(), null, new WorkspaceSelection("ws-a", ".")) + .sessionId(); + ManagedAgentProperties enabled = new ManagedAgentProperties(); + enabled.getHarness().setWorkspaceFilesEnabled(true); + ManagedAgentStore gated = new ManagedAgentStore(jdbc, mapper, + Clock.systemUTC(), ignored -> { + }, registry, enabled); + // The Workspace authority refuses the first attach and admits the + // second, so a rename after the refused one is observable. + UnavailableHarnessConnector harness = + new UnavailableHarnessConnector() { + private int attaches; + + @Override + public boolean isAvailable() { + return true; + } + + @Override + public boolean isWorkspaceFilesAvailable() { + return true; + } + + @Override + public Attachment createOrLoad(String tenantId, + String sessionId, boolean loadExisting) { + if (attaches++ == 0) { + throw WorkspaceExecutionStore.unavailable(); + } + return new Attachment("boot"); + } + + @Override + public void rename(String tenantId, String sessionId, + String title) { + } + }; + ManagedAgentService service = new ManagedAgentService(gated, + new RequestDigests(), null, harness, registry); + TransactionTemplate transaction = new TransactionTemplate( + transactionManager); + + // The refusal answers its own permanent status, and the command row + // it wrote must not survive to wedge every later rename. + transaction.executeWithoutResult(status -> + assertThatThrownBy(() -> service.renameSession(tenant, + "actor-a", "rename-1", sessionId, "first")) + .isInstanceOfSatisfying(ApiException.class, error -> { + assertThat(error.getStatus()) + .isEqualTo(HttpStatus.CONFLICT); + assertThat(error.getCode()) + .isEqualTo("workspace_unavailable"); + })); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM" + + " managed_agent_command WHERE tenant_id = ?" + + " AND session_id = ? AND command_status = 'PENDING'", + Integer.class, tenant, sessionId)).isZero(); + + transaction.executeWithoutResult(status -> service.renameSession( + tenant, "actor-a", "rename-2", sessionId, "second")); + assertThat(jdbc.queryForObject("SELECT title FROM" + + " managed_agent_session WHERE tenant_id = ?" + + " AND session_id = ?", String.class, tenant, + sessionId)).isEqualTo("second"); + } + @Test void enabledCreationRefusesPolicyDriftAndAnotherTenantsMount() { String tenant = "tenant-" + UUID.randomUUID(); From 3f0432b1c0e02622933e17effff87263f24d7db5 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 1 Oct 2026 22:32:11 +0800 Subject: [PATCH 19/73] refactor(managed-agent): drop the dead read-grant operand and fix the published rule maySubmitWorkspaceTurn's canRead operand, readGranted flag and 2-arg/3-arg overload pair cannot change any result: findReadable already joins the same access row with can_read = TRUE, and createdSession still runs first so an actor id the registry key cannot encode keeps answering false instead of throwing. The README's later-Turn rule now names the create grant and the registry's ACTIVE state, as the OpenAPI capability text already does. Addresses review threads R1-8 (PRRT_kwDOPB-92c6n-tVt) and R1-5 (PRRT_kwDOPB-92c6n-tV0, PRRT_kwDOPB-92c6n-tV-). Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmupkgkl21b --- .../sdk-java/managed-agent-server/README.md | 14 ++++++----- .../service/ManagedAgentService.java | 23 +++++++------------ 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/README.md b/packages/sdk-java/managed-agent-server/README.md index 1a8069a4210..8b97a00fccc 100644 --- a/packages/sdk-java/managed-agent-server/README.md +++ b/packages/sdk-java/managed-agent-server/README.md @@ -339,9 +339,10 @@ available. The directory mounted for a Workspace is trusted deployment data, not a filesystem sandbox. Later Turns may be submitted by the Session's creator under the -same opt-in while they can still read the Workspace (the per-caller -`workspaceTurns` capability flag reflects this), and the creator may cancel the -Session's running Turns and rename the Session. Close, archive, delete, +same opt-in while they can still read and create in the Workspace (the +per-caller `workspaceTurns` capability flag reflects the caller's current +grants and the Workspace registry's `ACTIVE` state), and the creator may cancel +the Session's running Turns and rename the Session. Close, archive, delete, unarchive and cwd operations and broad Workspace capability advertisement remain gated. Shell and in-flight recovery are separate slices. The existing `EmbeddedRuntimeBroker` is used through production configuration; @@ -472,9 +473,10 @@ local workloads. The opt-in W0e recovery above handles trusted host reboot; it does not provide physical isolation or recovery after worker-only death. Public bound Turn admission is limited to the opt-in initial file Turn described in G0 above and to later Turns submitted by the Session's creator under the same -opt-in while they can still read the Workspace (the per-caller `workspaceTurns` -capability flag reflects this); the creator may also cancel the Session's -running Turns and rename the Session. Later Turns run +opt-in while they can still read and create in the Workspace (the per-caller +`workspaceTurns` capability flag reflects the caller's current grants and the +registry's `ACTIVE` state); the creator may also cancel the Session's running +Turns and rename the Session. Later Turns run under the creator's Workspace grants, so any other actor keeps the existing refusal: `workspace_unavailable` when the actor can read the Workspace, `session_not_found` when they cannot. Public close, archive, delete and diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java index 0c2f6e22ffb..512753c95e7 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java @@ -561,7 +561,7 @@ private WebShellSession webShellSession(SessionRecord session, // Every Session serves its task list and detail; the tasks come from the // Stage H records its Session store holds (H0c). new WebShellSessionCapabilities(true, hasArtifacts(session), hasActions(session), - maySubmitWorkspaceTurn(session, actorId, true))); + maySubmitWorkspaceTurn(session, actorId))); } private static WebShellWorkspace webShellWorkspace(SessionRecord session) { @@ -718,14 +718,6 @@ private void requireSubmitter(String tenantId, String actorId, private boolean maySubmitWorkspaceTurn(SessionRecord session, String actorId) { - return maySubmitWorkspaceTurn(session, actorId, false); - } - - // readGranted is true on the read paths (session get/list), where the - // page query or requireReadGrant already established the caller's - // can_read for a bound row, so the clause would re-ask a fixed true. - private boolean maySubmitWorkspaceTurn(SessionRecord session, - String actorId, boolean readGranted) { if (session.workspace() == null || !harness.isWorkspaceFilesAvailable()) { return false; } @@ -739,15 +731,16 @@ private boolean maySubmitWorkspaceTurn(SessionRecord session, session.workspace().getContextConfigRef())) { return false; } - if ((!readGranted && !workspaces.canRead(session.tenantId(), actorId, - session.workspace().getWorkspaceId())) - || !workspaces.createdSession(session.tenantId(), actorId, - session.sessionId())) { + // createdSession precedes findReadable: it answers false for an actor + // id the registry key cannot encode, where findReadable throws. + if (!workspaces.createdSession(session.tenantId(), actorId, + session.sessionId())) { return false; } // The caller is the Session's creator, so this reads the creator's - // grant row, as the execution authority's join does: can_create on a - // registry whose state is ACTIVE. + // grant row, as the execution authority's join does: can_read (the + // join's own filter) and can_create, on a registry whose state is + // ACTIVE. ManagedWorkspaceRegistry.WorkspaceSummary summary = workspaces.findReadable(session.tenantId(), actorId, session.workspace().getWorkspaceId()); From f2f875228ea52979d0f4a2ed4abdd4c371c2d749 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Fri, 2 Oct 2026 00:10:28 +0900 Subject: [PATCH 20/73] refactor(managed-agent): cancel an admitted Turn without attaching bindHarness compares the given boot with the Session's bound boot, and an admitted Turn's boot is already bound, so the attach in cancelAdmittedTurn only recomputed it while re-running the Workspace authorization. Pass the bound boot instead and drop liveAttachment; harness.cancel already reuses the running Turn's attachment. Re-send at a fixed two-second interval instead of a backoff table. --- .../harness/HarnessConnector.java | 11 --- .../harness/QwenHostedHarnessConnector.java | 13 ---- .../service/HarnessCoordinator.java | 36 +++------ .../service/HarnessCoordinatorTest.java | 77 ++++++------------- 4 files changed, 37 insertions(+), 100 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessConnector.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessConnector.java index 4fa873168ec..46dd315c258 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessConnector.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessConnector.java @@ -3,7 +3,6 @@ import com.alibaba.qwen.code.daemon.HarnessRuntimeRecovery; import java.util.List; import java.util.Map; -import java.util.Optional; import com.fasterxml.jackson.databind.JsonNode; public interface HarnessConnector extends AutoCloseable { @@ -21,16 +20,6 @@ default Attachment createOrLoad(String tenantId, String sessionId, return createOrLoad(tenantId, sessionId, loadExisting); } - /** - * The attachment this connector already holds for the Session, if any. - * Reusing it runs no Workspace authorization, so aborting running work - * does not depend on the grants that admit new work. - */ - default Optional liveAttachment(String tenantId, - String sessionId) { - return Optional.empty(); - } - Admission submit(String tenantId, String sessionId, String promptId, List> input, String payloadDigest); diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java index bb3e7b75e5d..25c91c2bb6c 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java @@ -22,7 +22,6 @@ import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import com.fasterxml.jackson.databind.JsonNode; import com.alibaba.qwen.code.managedagent.store.ManagedActionStore; @@ -218,18 +217,6 @@ public void resolveAction( response.path("policyRevision").asText()); } - @Override - public Optional liveAttachment(String tenantId, - String sessionId) { - HarnessSessionRef attached = attachments.get( - new AttachmentKey(tenantId, sessionId)); - return attached == null ? Optional.empty() - : Optional.of(new Attachment(attached.getHarnessBootId(), - attached.getRuntimeRecovery(), - attached.getHarnessLastEventId(), - attached.getHarnessEventEpoch())); - } - @Override public void cancel(String tenantId, String sessionId) { client().cancelTurn(attachment(tenantId, sessionId, false)); diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java index 5fa7f85cfcf..70f649e6bcf 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java @@ -62,13 +62,11 @@ public class HarnessCoordinator { private final int batchMaxEvents; private final int batchMaxBytes; private final String owner = UUID.randomUUID().toString(); - // A cancel the Harness did not take is re-sent with these delays (the - // last one repeating) while its Turn is still CANCELLING. The running - // dispatcher checks CANCELLING only once, before it starts streaming, so - // nothing else re-sends it. - private static final long[] CANCEL_RETRY_MILLIS = {1_000, 2_000, 5_000, - 10_000}; - private static final int CANCEL_RETRY_LIMIT = 60; + // A cancel the Harness did not take is re-sent while its Turn is still + // CANCELLING: the running dispatcher checks CANCELLING only once, before + // it starts streaming, so nothing else re-sends it. + private static final long CANCEL_RETRY_MILLIS = 2_000; + private static final int CANCEL_RETRY_LIMIT = 150; private final Set active = ConcurrentHashMap.newKeySet(); private final ScheduledExecutorService renewer = Executors.newSingleThreadScheduledExecutor(runnable -> { @@ -558,20 +556,12 @@ private void cancelAdmittedTurn(String tenantId, String sessionId, && !harness.isWorkspaceFilesAvailable()) { return; } - // A live cancel reuses the running Turn's attachment, which runs - // no Workspace authorization: aborting running work must not - // depend on the grants that admit new work. A passive attach - // would reload the Session in the Harness, so the abort would - // reach a different attachment and the Turn would stay - // CANCELLING; only cancellation recovery, which has no live - // attachment, may attach passively. - Attachment attachment = harness.liveAttachment( - session.tenantId(), session.sessionId()) - .orElseGet(() -> harness.createOrLoad(session.tenantId(), - session.sessionId(), - session.harnessBootId() != null)); - if (store.bindHarness(tenantId, sessionId, - turnId, owner, attachment.bootId())) { + // An admitted Turn's boot is already bound, so the cancel needs no + // attach: attaching re-runs the Workspace authorization, and + // aborting running work must not depend on the grants that admit + // new work. harness.cancel reuses the running Turn's attachment. + if (session.harnessBootId() != null && store.bindHarness(tenantId, + sessionId, turnId, owner, session.harnessBootId())) { harness.cancel(session.tenantId(), session.sessionId()); } } catch (RuntimeException error) { @@ -590,11 +580,9 @@ private void retryCancellation(String tenantId, String sessionId, + " session={} turn={}", tenantId, sessionId, turnId); return; } - long delay = CANCEL_RETRY_MILLIS[Math.min(attempt, - CANCEL_RETRY_MILLIS.length) - 1]; try { renewer.schedule(() -> executor.execute(() -> cancelAdmittedTurn( - tenantId, sessionId, turnId, attempt)), delay, + tenantId, sessionId, turnId, attempt)), CANCEL_RETRY_MILLIS, TimeUnit.MILLISECONDS); } catch (RejectedExecutionException closed) { // The coordinator is shutting down; recovery takes over. diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java index 6f5eeb8b4a0..c9fcd0b0343 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java @@ -8,6 +8,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.after; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -237,44 +238,14 @@ void cancelsABoundTurnThroughTheHarnessWithTheWorkspaceOptIn() { AgentStateStore store = boundCancellingStore(); HarnessConnector harness = mock(HarnessConnector.class); when(harness.isWorkspaceFilesAvailable()).thenReturn(true); - when(harness.createOrLoad("tenant", "session", true)) - .thenReturn(new Attachment("boot", null, null, null)); when(store.bindHarness(eq("tenant"), eq("session"), eq("turn"), anyString(), eq("boot"))).thenReturn(true); - HarnessCoordinator coordinator = new HarnessCoordinator(store, harness, - new HarnessEventProjector(), mock(RuntimeWarmer.class), - directExecutor(), Clock.systemUTC(), - new ManagedAgentProperties()); + HarnessCoordinator coordinator = coordinator(store, harness); try { coordinator.cancel("tenant", "session", "turn"); - // The live cancel keeps the running attachment; a passive reload - // would leave the abort on a different one. - verify(harness).createOrLoad("tenant", "session", true); - verify(harness, never()).createOrLoad("tenant", "session", true, - true); - verify(harness).cancel("tenant", "session"); - } finally { - coordinator.close(); - } - } - - @Test - void cancelsThroughTheLiveAttachmentWithoutReauthorizing() { - AgentStateStore store = boundCancellingStore(); - HarnessConnector harness = mock(HarnessConnector.class); - when(harness.isWorkspaceFilesAvailable()).thenReturn(true); - when(harness.liveAttachment("tenant", "session")).thenReturn( - Optional.of(new Attachment("boot", null, null, null))); - when(store.bindHarness(eq("tenant"), eq("session"), eq("turn"), - anyString(), eq("boot"))).thenReturn(true); - HarnessCoordinator coordinator = new HarnessCoordinator(store, harness, - new HarnessEventProjector(), mock(RuntimeWarmer.class), - directExecutor(), Clock.systemUTC(), - new ManagedAgentProperties()); - try { - coordinator.cancel("tenant", "session", "turn"); - // The live attachment runs no Workspace authorization, so a - // revoked grant or a draining Workspace cannot stop the abort. + // The admitted Turn's boot is already bound, so the cancel never + // attaches: an attach re-runs the Workspace authorization, which + // a revoked grant or a draining Workspace would refuse. verify(harness, never()).createOrLoad(anyString(), anyString(), anyBoolean()); verify(harness, never()).createOrLoad(anyString(), anyString(), @@ -290,21 +261,17 @@ void resendsACancelTheHarnessDidNotTake() { AgentStateStore store = boundCancellingStore(); HarnessConnector harness = mock(HarnessConnector.class); when(harness.isWorkspaceFilesAvailable()).thenReturn(true); - when(harness.createOrLoad("tenant", "session", true)) - .thenThrow(new IllegalStateException("refused")) - .thenReturn(new Attachment("boot", null, null, null)); when(store.bindHarness(eq("tenant"), eq("session"), eq("turn"), anyString(), eq("boot"))).thenReturn(true); - HarnessCoordinator coordinator = new HarnessCoordinator(store, harness, - new HarnessEventProjector(), mock(RuntimeWarmer.class), - directExecutor(), Clock.systemUTC(), - new ManagedAgentProperties()); + doThrow(new IllegalStateException("lost")).doNothing() + .when(harness).cancel("tenant", "session"); + HarnessCoordinator coordinator = coordinator(store, harness); try { coordinator.cancel("tenant", "session", "turn"); // The running dispatcher checks CANCELLING only once, so the // retry is what delivers the cancel after the first failure. - verify(harness, timeout(5_000)).cancel("tenant", "session"); - verify(harness, times(2)).createOrLoad("tenant", "session", true); + verify(harness, timeout(5_000).times(2)).cancel("tenant", + "session"); } finally { coordinator.close(); } @@ -320,22 +287,28 @@ void stopsResendingOnceTheTurnIsNoLongerCancelling() { "epoch", 1, "COMPLETED"))); HarnessConnector harness = mock(HarnessConnector.class); when(harness.isWorkspaceFilesAvailable()).thenReturn(true); - when(harness.createOrLoad("tenant", "session", true)) - .thenThrow(new IllegalStateException("refused")); - HarnessCoordinator coordinator = new HarnessCoordinator(store, harness, - new HarnessEventProjector(), mock(RuntimeWarmer.class), - directExecutor(), Clock.systemUTC(), - new ManagedAgentProperties()); + when(store.bindHarness(eq("tenant"), eq("session"), eq("turn"), + anyString(), eq("boot"))).thenReturn(true); + doThrow(new IllegalStateException("lost")).when(harness) + .cancel("tenant", "session"); + HarnessCoordinator coordinator = coordinator(store, harness); try { coordinator.cancel("tenant", "session", "turn"); - verify(harness, after(2_500).times(1)).createOrLoad("tenant", - "session", true); - verify(harness, never()).cancel("tenant", "session"); + verify(harness, after(3_000).times(1)).cancel("tenant", + "session"); } finally { coordinator.close(); } } + private static HarnessCoordinator coordinator(AgentStateStore store, + HarnessConnector harness) { + return new HarnessCoordinator(store, harness, + new HarnessEventProjector(), mock(RuntimeWarmer.class), + directExecutor(), Clock.systemUTC(), + new ManagedAgentProperties()); + } + private static AgentStateStore boundCancellingStore() { AgentStateStore store = mock(AgentStateStore.class); when(store.findTurn("tenant", "session", "turn")).thenReturn(Optional.of( From 179aa0551576948917c9ea3fca2c506d4dd8c2c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Thu, 1 Oct 2026 23:21:15 +0800 Subject: [PATCH 21/73] docs(managed-agent): state the full later-Turn admission rule and narrow the bound-Session notice The published rule on createSession, webShellCreateSession and the workspaceTurns description omitted the caller's live read and create grants and the registry's ACTIVE state, so the public surface could not discover the rule maySubmitWorkspaceTurn actually applies; the public descriptions now also say there is no per-caller flag there and callers must handle 409 workspace_unavailable. The bound-Session notice claimed message execution "is not available in this service yet", which is false for every per-caller refusal (not the creator, grant revoked, Workspace draining) while the deployment serves the creator fine; narrow the copy to "You cannot send messages in this Session" so it is true for both causes. The webShellCreationIsMetadataOnlyUntilExecutionIsWired comment claimed to pin the isWorkspaceFilesAvailable clause though its 3-arg registration's drifted profile refs make the refusal over-determined; say so instead. Addresses review threads R3-4 (PRRT_kwDOPB-92c6n-tWn), R3-6 (PRRT_kwDOPB-92c6n-tWw) and the R3-2 comment anchor (PRRT_kwDOPB-92c6n-tWZ). Co-authored-by: Qwen-Coder --- .../resources/openapi/managed-agent-public-api.openapi.json | 6 +++--- .../code/managedagent/ManagedWorkspaceAdmissionTest.java | 5 +++-- .../client/components/managed/ManagedSessionsPage.test.tsx | 4 ++-- .../components/managed/generated/managed-agent-api.ts | 4 ++-- packages/web-shell/client/i18n.tsx | 4 ++-- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json index e0b168fdc01..1bbf08699bd 100644 --- a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json +++ b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json @@ -162,7 +162,7 @@ "tags": ["Public Sessions"], "operationId": "createSession", "x-qwen-implementation-status": "implemented", - "description": "Workspace selection is persisted with the original actor-scoped creation receipt. With the deployment's G0 file admission opt-in, qwen-code Sessions in an authorized, preregistered Workspace may include initial input under the fixed preapproved file-tool profile. Public callers cannot select the profile. Otherwise Workspace creation must have empty input. The Session creator may submit later Turns and cancel its running Turns under the same opt-in, and may rename the Session; close, archive, delete, unarchive and cwd operations remain gated. Check replay before resolving defaults; a durable binding is not proof of Runtime readiness.", + "description": "Workspace selection is persisted with the original actor-scoped creation receipt. With the deployment's G0 file admission opt-in, qwen-code Sessions in an authorized, preregistered Workspace may include initial input under the fixed preapproved file-tool profile. Public callers cannot select the profile. Otherwise Workspace creation must have empty input. The Session creator may submit later Turns and cancel its running Turns under the same opt-in, and may rename the Session, while the creator currently holds Workspace read and create grants on a registry row whose state is ACTIVE and the Session is an active, undeleted qwen-code Session on the frozen execution profile; close, archive, delete, unarchive and cwd operations remain gated. This surface has no per-caller capability flag for later Turns, so callers must handle 409 workspace_unavailable. Check replay before resolving defaults; a durable binding is not proof of Runtime readiness.", "parameters": [ { "$ref": "#/components/parameters/IdempotencyKey" @@ -1107,7 +1107,7 @@ "tags": ["WebShell"], "operationId": "webShellCreateSession", "x-qwen-implementation-status": "implemented", - "description": "Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns and cancel its running Turns under the same opt-in, and may rename the Session; close, archive, delete, unarchive and cwd operations remain gated. Freeze selection with the original idempotency key; admission does not prove physical directory readiness.", + "description": "Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns and cancel its running Turns under the same opt-in, and may rename the Session, while the creator currently holds Workspace read and create grants on a registry row whose state is ACTIVE and the Session is an active, undeleted qwen-code Session on the frozen execution profile; close, archive, delete, unarchive and cwd operations remain gated. The per-caller workspaceTurns capability on this surface advertises the same rule. Freeze selection with the original idempotency key; admission does not prove physical directory readiness.", "requestBody": { "required": true, "content": { @@ -4636,7 +4636,7 @@ "workspaceTurns": { "type": "boolean", "default": false, - "description": "True when the caller may submit later Turns of this Workspace-bound Session, cancel its running Turns and rename it: the deployment enables Workspace files, the caller created the Session and currently holds Workspace read and create grants, and the Session is an active qwen-code Session on the frozen execution profile. False for every other caller and for unbound Sessions, which do not use it." + "description": "True when the caller may submit later Turns of this Workspace-bound Session, cancel its running Turns and rename it: the deployment enables Workspace files, the caller created the Session and currently holds Workspace read and create grants on a registry row whose state is ACTIVE, and the Session is an active qwen-code Session on the frozen execution profile. False for every other caller and for unbound Sessions, which do not use it." }, "tasks": { "type": "boolean" diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java index 0f1a7e8dbca..4b67dc4a004 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java @@ -842,8 +842,9 @@ void webShellCreationIsMetadataOnlyUntilExecutionIsWired() .andExpect(jsonPath("$.workspace.workspaceId") .value("ws-a")) .andExpect(jsonPath("$.workspace.cwdRelative").value("services/api")) - // The opt-in is off, so even the creator may not send later - // Turns; this pins the isWorkspaceFilesAvailable clause. + // The opt-in is off and the 3-arg registration's profile + // refs are drifted, so the refusal is over-determined and + // cannot isolate the isWorkspaceFilesAvailable clause. .andExpect(jsonPath("$.capabilities.workspaceTurns") .value(false)); mvc.perform(post("/api/agent/web-shell/v1/sessions/create") diff --git a/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx b/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx index 5ef5d60048c..0786c4f80a3 100644 --- a/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx +++ b/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx @@ -614,7 +614,7 @@ describe('ManagedSessionsPage', () => { ).toContain('services/api'); expect( container.querySelector('[data-managed-workspace-binding]')?.textContent, - ).toContain('Message execution is not available'); + ).toContain('You cannot send messages in this Session'); expect(container.querySelector('[data-managed-progress]')).toBeNull(); expect(container.querySelector('textarea')).toBeNull(); expect(container.textContent).not.toContain('Preparing environment'); @@ -639,7 +639,7 @@ describe('ManagedSessionsPage', () => { ).toContain('ws-a'); expect( container.querySelector('[data-managed-workspace-binding]')?.textContent, - ).not.toContain('Message execution is not available'); + ).not.toContain('You cannot send messages in this Session'); expect(container.querySelector('textarea')).not.toBeNull(); await input('Run it again'); await click('Send'); diff --git a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts index 2a830cc45b6..ca67618890c 100644 --- a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts +++ b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts @@ -76,7 +76,7 @@ export interface paths { }; get?: never; put?: never; - /** @description Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns and cancel its running Turns under the same opt-in, and may rename the Session; close, archive, delete, unarchive and cwd operations remain gated. Freeze selection with the original idempotency key; admission does not prove physical directory readiness. */ + /** @description Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns and cancel its running Turns under the same opt-in, and may rename the Session, while the creator currently holds Workspace read and create grants on a registry row whose state is ACTIVE and the Session is an active, undeleted qwen-code Session on the frozen execution profile; close, archive, delete, unarchive and cwd operations remain gated. The per-caller workspaceTurns capability on this surface advertises the same rule. Freeze selection with the original idempotency key; admission does not prove physical directory readiness. */ post: operations["webShellCreateSession"]; delete?: never; options?: never; @@ -477,7 +477,7 @@ export interface components { /** @default false */ actions: boolean; /** - * @description True when the caller may submit later Turns of this Workspace-bound Session, cancel its running Turns and rename it: the deployment enables Workspace files, the caller created the Session and currently holds Workspace read and create grants, and the Session is an active qwen-code Session on the frozen execution profile. False for every other caller and for unbound Sessions, which do not use it. + * @description True when the caller may submit later Turns of this Workspace-bound Session, cancel its running Turns and rename it: the deployment enables Workspace files, the caller created the Session and currently holds Workspace read and create grants on a registry row whose state is ACTIVE, and the Session is an active qwen-code Session on the frozen execution profile. False for every other caller and for unbound Sessions, which do not use it. * @default false */ workspaceTurns?: boolean; diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 85516833f78..347242c6ba7 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -177,7 +177,7 @@ const EN: Messages = { 'managed.workspaceCreate': 'Create session', 'managed.workspaceBound': 'Bound Workspace', 'managed.workspaceExecutionUnavailable': - 'Workspace is bound. Message execution is not available in this service yet.', + 'Workspace is bound. You cannot send messages in this Session.', 'managed.workspaceSharedFiles': 'Sessions in the same Workspace share files. Directory availability is checked before execution.', 'managed.workspaceEmpty': 'No readable Workspaces are available.', @@ -4387,7 +4387,7 @@ const ZH: Messages = { 'managed.workspaceCreate': '创建会话', 'managed.workspaceBound': '已绑定工作区', 'managed.workspaceExecutionUnavailable': - '工作区已绑定;当前服务暂未开放消息执行。', + '工作区已绑定;你不能在此会话中发送消息。', 'managed.workspaceSharedFiles': '同一工作区的会话共享文件;目录可用性将在执行前验证。', 'managed.workspaceEmpty': '没有可读取的工作区。', From 8a26cc9ce504e0fb153978eaf09e993ff257c4a9 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 2 Oct 2026 01:09:46 +0800 Subject: [PATCH 22/73] fix(managed-agent): deliver cancellation through the running owner --- ...09-29-hosted-public-workspace-admission.md | 5 +- ...hosted-public-workspace-admission.zh-CN.md | 2 +- .../service/HarnessCoordinator.java | 36 ++---- .../service/HarnessCoordinatorTest.java | 109 ++++++++++++------ 4 files changed, 86 insertions(+), 66 deletions(-) diff --git a/docs/design/2026-09-29-hosted-public-workspace-admission.md b/docs/design/2026-09-29-hosted-public-workspace-admission.md index 015375cec2c..592780aa2ac 100644 --- a/docs/design/2026-09-29-hosted-public-workspace-admission.md +++ b/docs/design/2026-09-29-hosted-public-workspace-admission.md @@ -33,7 +33,10 @@ Cancelling only aborts work already running: the creator who can still read the Workspace may cancel even after the create grant is revoked, the Workspace starts draining or it is re-registered. A live cancel reuses the running Turn's attachment without re-running the execution authority, and a cancel the -Harness did not take is re-sent while the Turn is still cancelling. +Harness did not take is re-sent while the Turn is still cancelling. After each +successful lease renewal, the running owner observes cancellation requested +through any API replica and sends it on the executor, keeping network waits +off the lease scheduler. Failed deliveries retry at the lease renewal interval. Close, archive, delete, unarchive and cwd operations remain gated: the Runtime Broker's drain only stops warming a closed Session and has no Harness-level teardown yet. diff --git a/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md b/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md index c11bd17a9cd..7a692eba500 100644 --- a/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md +++ b/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md @@ -10,7 +10,7 @@ Hosted Read/Write/Edit 已能经过生产 Broker 和 worker 执行,但目前 G0 开放随创建会话准入的一次初始文件工具轮次,使用现有公开 REST 路由及共享 service 的 WebShell 创建适配器。发现接口仍仅广播 Workspace 绑定能力,不宣称完整的 Workspace 执行支持。G0 本身无需修改 UI;后续改动唯一的 UI 变化是为创建者开放输入框与取消控件。 -后续改动在同一开关下为会话创建者开放后续 Turn,并允许创建者取消正在运行的 Turn,由 Hosted Harness 中止该 Turn 并按原始 Runtime 身份结算。执行时每个 Turn 都按创建者的 Workspace 授权校验,因此其他 actor 以及未开启该开关的部署仍得到现有拒绝:actor 可读取该 Workspace 时为 `workspace_unavailable`,不能读取时为 `session_not_found`。创建者也可以重命名该会话。准入新工作要求创建者在 `ACTIVE` 的 Workspace 上持有创建授权,且 Workspace 的 generation 与存储与会话绑定时一致,因此重新注册后提交和改名会在写入任何命令之前被拒绝。取消只中止已在运行的工作:仍能读取该 Workspace 的创建者,即使创建授权被撤销、Workspace 进入 drain 或被重新注册,也可以取消。正常取消复用运行中 Turn 的挂接,不再执行执行授权;Harness 未接收的取消会在 Turn 仍处于取消中时重发。关闭、归档、删除、取消归档与 cwd 操作仍保持门禁:Runtime Broker 的 drain 只是停止为已关闭会话预热,尚无 Harness 级别的回收。 +后续改动在同一开关下为会话创建者开放后续 Turn,并允许创建者取消正在运行的 Turn,由 Hosted Harness 中止该 Turn 并按原始 Runtime 身份结算。执行时每个 Turn 都按创建者的 Workspace 授权校验,因此其他 actor 以及未开启该开关的部署仍得到现有拒绝:actor 可读取该 Workspace 时为 `workspace_unavailable`,不能读取时为 `session_not_found`。创建者也可以重命名该会话。准入新工作要求创建者在 `ACTIVE` 的 Workspace 上持有创建授权,且 Workspace 的 generation 与存储与会话绑定时一致,因此重新注册后提交和改名会在写入任何命令之前被拒绝。取消只中止已在运行的工作:仍能读取该 Workspace 的创建者,即使创建授权被撤销、Workspace 进入 drain 或被重新注册,也可以取消。正常取消复用运行中 Turn 的挂接,不再执行执行授权;Harness 未接收的取消会在 Turn 仍处于取消中时重发。每次成功续租后,执行 owner 检查任意 API 副本受理的取消请求,并在 executor 中发送,避免网络等待阻塞续租调度器。投递失败按续租间隔重试。关闭、归档、删除、取消归档与 cwd 操作仍保持门禁:Runtime Broker 的 drain 只是停止为已关闭会话预热,尚无 Harness 级别的回收。 ## 决策 diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java index 70f649e6bcf..9b112c12279 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java @@ -33,7 +33,6 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; -import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -62,11 +61,6 @@ public class HarnessCoordinator { private final int batchMaxEvents; private final int batchMaxBytes; private final String owner = UUID.randomUUID().toString(); - // A cancel the Harness did not take is re-sent while its Turn is still - // CANCELLING: the running dispatcher checks CANCELLING only once, before - // it starts streaming, so nothing else re-sends it. - private static final long CANCEL_RETRY_MILLIS = 2_000; - private static final int CANCEL_RETRY_LIMIT = 150; private final Set active = ConcurrentHashMap.newKeySet(); private final ScheduledExecutorService renewer = Executors.newSingleThreadScheduledExecutor(runnable -> { @@ -127,7 +121,7 @@ public void dispatch(String tenantId, String sessionId, String turnId) { public void cancel(String tenantId, String sessionId, String turnId) { dispatch(tenantId, sessionId, turnId); executor.execute(() -> cancelAdmittedTurn(tenantId, sessionId, - turnId, 0)); + turnId)); } @Scheduled(fixedDelayString = @@ -161,6 +155,9 @@ private void coordinate(String tenantId, String sessionId, if (!store.renewTurn(tenantId, sessionId, turnId, owner, leaseDuration)) { leaseLost.set(true); + } else if (!leaseLost.get()) { + executor.execute(() -> cancelAdmittedTurn( + tenantId, sessionId, turnId)); } } catch (RuntimeException error) { leaseLost.set(true); @@ -538,7 +535,7 @@ private void runtimeWarmResult(SessionRecord session, TurnRecord turn, } private void cancelAdmittedTurn(String tenantId, String sessionId, - String turnId, int attempt) { + String turnId) { try { TurnRecord turn = store.findTurn(tenantId, sessionId, turnId) .orElse(null); @@ -565,27 +562,10 @@ private void cancelAdmittedTurn(String tenantId, String sessionId, harness.cancel(session.tenantId(), session.sessionId()); } } catch (RuntimeException error) { - LOG.warn("Managed Turn cancellation will retry tenant={}" - + " session={} turn={} attempt={} failure={}", - tenantId, sessionId, turnId, attempt, + LOG.warn("Managed Turn cancellation awaits lease renewal tenant={}" + + " session={} turn={} failure={}", + tenantId, sessionId, turnId, error.getClass().getSimpleName()); - retryCancellation(tenantId, sessionId, turnId, attempt + 1); - } - } - - private void retryCancellation(String tenantId, String sessionId, - String turnId, int attempt) { - if (attempt > CANCEL_RETRY_LIMIT) { - LOG.warn("Managed Turn cancellation stopped retrying tenant={}" - + " session={} turn={}", tenantId, sessionId, turnId); - return; - } - try { - renewer.schedule(() -> executor.execute(() -> cancelAdmittedTurn( - tenantId, sessionId, turnId, attempt)), CANCEL_RETRY_MILLIS, - TimeUnit.MILLISECONDS); - } catch (RejectedExecutionException closed) { - // The coordinator is shutting down; recovery takes over. } } diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java index c9fcd0b0343..c1220c853cf 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java @@ -1,5 +1,6 @@ package com.alibaba.qwen.code.managedagent.service; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyLong; @@ -8,12 +9,10 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.after; import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.timeout; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions; @@ -41,8 +40,13 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -256,48 +260,81 @@ void cancelsABoundTurnThroughTheHarnessWithTheWorkspaceOptIn() { } } - @Test - void resendsACancelTheHarnessDidNotTake() { - AgentStateStore store = boundCancellingStore(); - HarnessConnector harness = mock(HarnessConnector.class); - when(harness.isWorkspaceFilesAvailable()).thenReturn(true); - when(store.bindHarness(eq("tenant"), eq("session"), eq("turn"), - anyString(), eq("boot"))).thenReturn(true); - doThrow(new IllegalStateException("lost")).doNothing() - .when(harness).cancel("tenant", "session"); - HarnessCoordinator coordinator = coordinator(store, harness); - try { - coordinator.cancel("tenant", "session", "turn"); - // The running dispatcher checks CANCELLING only once, so the - // retry is what delivers the cancel after the first failure. - verify(harness, timeout(5_000).times(2)).cancel("tenant", - "session"); - } finally { - coordinator.close(); - } - } - - @Test - void stopsResendingOnceTheTurnIsNoLongerCancelling() { + @ParameterizedTest + @ValueSource(strings = {"accepted", "retry", "lease-lost", "completed"}) + void runningOwnerObservesCancellationAfterStreamingStarts(String mode) + throws Exception { AgentStateStore store = boundCancellingStore(); - when(store.findTurn("tenant", "session", "turn")).thenReturn( - Optional.of(turn("tenant", "session", "turn", "prompt", - "epoch", 1, "CANCELLING")), - Optional.of(turn("tenant", "session", "turn", "prompt", - "epoch", 1, "COMPLETED"))); + TurnRecord running = turn("tenant", "session", "turn", "prompt", + "epoch", 1); + AtomicReference current = new AtomicReference<>(running); + when(store.claimTurn(eq("tenant"), eq("session"), eq("turn"), + anyString(), any(Duration.class))) + .thenReturn(Optional.of(running)); + when(store.findTurn("tenant", "session", "turn")) + .thenAnswer(invocation -> Optional.of(current.get())); + when(store.renewTurn(eq("tenant"), eq("session"), eq("turn"), + anyString(), any(Duration.class))) + .thenReturn(!"lease-lost".equals(mode)); HarnessConnector harness = mock(HarnessConnector.class); when(harness.isWorkspaceFilesAvailable()).thenReturn(true); when(store.bindHarness(eq("tenant"), eq("session"), eq("turn"), anyString(), eq("boot"))).thenReturn(true); - doThrow(new IllegalStateException("lost")).when(harness) - .cancel("tenant", "session"); - HarnessCoordinator coordinator = coordinator(store, harness); + when(harness.createOrLoad("tenant", "session", true)) + .thenReturn(new Attachment("boot")); + CountDownLatch streaming = new CountDownLatch(1); + CountDownLatch cancelled = new CountDownLatch(1); + SourceStream stream = mock(SourceStream.class); + when(stream.eventEpoch()).thenReturn("epoch"); + when(stream.next()).thenAnswer(invocation -> { + cancelled.await(); + return new SourceEvent(2L, "turn_complete", + Map.of("stopReason", "cancelled"), "prompt", Map.of()); + }).thenReturn(null); + when(harness.stream("tenant", "session", 1, "epoch")) + .thenAnswer(invocation -> { + streaming.countDown(); + return stream; + }); + AtomicBoolean loseDelivery = new AtomicBoolean("retry".equals(mode)); + doAnswer(invocation -> { + if (loseDelivery.getAndSet(false)) + throw new IllegalStateException("lost"); + current.set(turn("tenant", "session", "turn", "prompt", + "epoch", 2, "CANCELLED")); + cancelled.countDown(); + return null; + }).when(harness).cancel("tenant", "session"); + ManagedAgentProperties properties = new ManagedAgentProperties(); + properties.getDispatch().setLeaseRenewInterval(Duration.ofMillis(20)); + ExecutorService executor = Executors.newCachedThreadPool(); + HarnessCoordinator coordinator = new HarnessCoordinator(store, harness, + new HarnessEventProjector(), mock(RuntimeWarmer.class), + executor, Clock.systemUTC(), properties); try { - coordinator.cancel("tenant", "session", "turn"); - verify(harness, after(3_000).times(1)).cancel("tenant", - "session"); + coordinator.dispatch("tenant", "session", "turn"); + assertTrue(streaming.await(2, TimeUnit.SECONDS)); + // Another API replica persists this state without calling the + // running owner's coordinator directly. + current.set(turn("tenant", "session", "turn", "prompt", + "epoch", 1, "completed".equals(mode) + ? "COMPLETED" : "CANCELLING")); + verify(store, timeout(2_000).atLeastOnce()).renewTurn(eq("tenant"), + eq("session"), eq("turn"), anyString(), + any(Duration.class)); + if ("accepted".equals(mode) || "retry".equals(mode)) { + assertTrue(cancelled.await(2, TimeUnit.SECONDS)); + verify(harness, timeout(2_000).times( + "retry".equals(mode) ? 2 : 1)) + .cancel("tenant", "session"); + } else { + verify(harness, after(200).never()).cancel(anyString(), + anyString()); + } } finally { + cancelled.countDown(); coordinator.close(); + executor.shutdownNow(); } } From 925cffe6dce60d824a91ee5858ac32c7f774aa75 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 2 Oct 2026 01:48:30 +0800 Subject: [PATCH 23/73] fix(ci): bound hosted browser dependency installation --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9ddfaa288f..d1df75354ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1522,7 +1522,9 @@ jobs: - name: 'Install Playwright Chromium and WebKit (hosted)' if: "${{ runner.environment == 'github-hosted' }}" + timeout-minutes: 8 run: |- + printf '%s\n' 'Acquire::http::Timeout "30";' 'Acquire::https::Timeout "30";' 'Acquire::Retries "2";' | sudo tee /etc/apt/apt.conf.d/99-qwen-ci-timeouts > /dev/null node node_modules/playwright/cli.js install --with-deps chromium webkit nested_cli='node_modules/@playwright/test/node_modules/playwright/cli.js' if [ -f "${nested_cli}" ]; then From 77373a06186cf3f1a48cdb36e58b34348b365b91 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 2 Oct 2026 02:56:32 +0800 Subject: [PATCH 24/73] fix(ci): record the ci.yml growth in the workflow size baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hosted-browser install step adds two lines to ci.yml, taking it to 147136 bytes: 4110 over the recorded 143026 and 14 past the 4096-byte allowance, which is why "Check workflow file size" failed. Most of that growth is main-side drift the allowance had been absorbing, but this PR touches the file, so the ratchet's stale-baseline leniency does not apply and the number has to move in the same PR. The growth is real — a bounded apt/Playwright install — so bump the entry instead of shrinking the workflow. Verified locally: .github/scripts/check-workflow-size.sh exits 0 against base a7deb01bcb, and scripts/tests/workflow-size.test.js passes 220/220. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-conflict/jmupvw3d00b --- .github/workflows/.size-baseline | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index 89844b9bd39..ad010b634c8 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -19,7 +19,7 @@ 9256 build-and-publish-image.yml 50773 cd-cua-driver.yml 2222 cd-mobile-mcp.yml -143026 ci.yml +147136 ci.yml 1482 codeql.yml 9389 comment-attachment-guard.yml 1634 desktop-packaging-check.yml From 4a79dcfb91e452872bcd5c6c4133e867a16c71d6 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 2 Oct 2026 03:42:43 +0800 Subject: [PATCH 25/73] fix(ci): reserve browser smoke time after slow dependency downloads --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ed79707ed8..42016703994 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1362,9 +1362,9 @@ jobs: # document performance budget on ECS, while both gates passed on hosted. # Keep this browser job hosted without relaxing its test assertions. runs-on: 'ubuntu-latest' - # 184 smoke tests plus ~7.5 min of setup now finish right at 20 min, so - # passing runs get cancelled in cleanup. Sharding is the real fix. - timeout-minutes: 30 + # Browser dependencies took 22 min on a slow hosted mirror; leave time + # for the 208 smoke tests and artifact cleanup after installation. + timeout-minutes: 45 permissions: contents: 'read' steps: @@ -1533,7 +1533,7 @@ jobs: - name: 'Install Playwright Chromium and WebKit (hosted)' if: "${{ runner.environment == 'github-hosted' }}" - timeout-minutes: 8 + timeout-minutes: 25 run: |- printf '%s\n' 'Acquire::http::Timeout "30";' 'Acquire::https::Timeout "30";' 'Acquire::Retries "2";' | sudo tee /etc/apt/apt.conf.d/99-qwen-ci-timeouts > /dev/null node node_modules/playwright/cli.js install --with-deps chromium webkit From ba222e2429bd870f49064f5ef5cb5dd50daab6cb Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 2 Oct 2026 04:13:24 +0800 Subject: [PATCH 26/73] fix(ci): keep time for smoke after slow hosted installs --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42016703994..0fd431579e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1362,9 +1362,9 @@ jobs: # document performance budget on ECS, while both gates passed on hosted. # Keep this browser job hosted without relaxing its test assertions. runs-on: 'ubuntu-latest' - # Browser dependencies took 22 min on a slow hosted mirror; leave time - # for the 208 smoke tests and artifact cleanup after installation. - timeout-minutes: 45 + # Slow hosted mirrors kept downloading past 23 min; reserve time for + # the 208 smoke tests and artifact cleanup after installation. + timeout-minutes: 60 permissions: contents: 'read' steps: @@ -1533,7 +1533,7 @@ jobs: - name: 'Install Playwright Chromium and WebKit (hosted)' if: "${{ runner.environment == 'github-hosted' }}" - timeout-minutes: 25 + timeout-minutes: 30 run: |- printf '%s\n' 'Acquire::http::Timeout "30";' 'Acquire::https::Timeout "30";' 'Acquire::Retries "2";' | sudo tee /etc/apt/apt.conf.d/99-qwen-ci-timeouts > /dev/null node node_modules/playwright/cli.js install --with-deps chromium webkit From 9dffa0120d8d6ff62d8faac0d0b7bc8945d7566c Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 2 Oct 2026 04:55:28 +0800 Subject: [PATCH 27/73] test(web-shell): wait for step expansion before selecting next group --- packages/web-shell/client/e2e/web-shell.command-card.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web-shell/client/e2e/web-shell.command-card.spec.ts b/packages/web-shell/client/e2e/web-shell.command-card.spec.ts index 8ab06fd5c09..8126a8c1ef5 100644 --- a/packages/web-shell/client/e2e/web-shell.command-card.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.command-card.spec.ts @@ -100,6 +100,7 @@ test('shell card separates output, reveals and copies commands, and retains fail }); await expect(collapsedSteps).toHaveCount(2); await collapsedSteps.first().click(); + await expect(collapsedSteps).toHaveCount(1); await collapsedSteps.first().click(); await page .getByRole('button', { name: 'Wait for daemon health', exact: true }) From b73ec018e92d202907787ebd146e4717241cdcb2 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 2 Oct 2026 05:21:05 +0800 Subject: [PATCH 28/73] test(ci): align hosted browser timeout contract --- scripts/tests/ci-platform-lanes.test.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/scripts/tests/ci-platform-lanes.test.js b/scripts/tests/ci-platform-lanes.test.js index 692a5501311..d882394ed2d 100644 --- a/scripts/tests/ci-platform-lanes.test.js +++ b/scripts/tests/ci-platform-lanes.test.js @@ -104,13 +104,11 @@ it('keeps lint_and_static sized for cold-cache pool runs', () => { it('keeps browser gates hosted independently of the shared Linux runner', () => { expect(ci.jobs.web_shell_e2e_smoke['runs-on']).toBe('ubuntu-latest'); - // 30, not 20: at 184 smoke tests plus ~7.5 min of setup, passing runs hit - // 20 flat (tests done at 20:07:49, cancelled 20:07:53; the last of 184 - // still running at 02:04:58, cancelled 02:05:04). Sharding is the fix at - // the source once the suite keeps growing. - expect(timeoutMinutesOn('web_shell_e2e_smoke', ECS_RUNNER)).toBe(30); - expect(timeoutMinutesOn('web_shell_e2e_smoke', HOSTED_RUNNER)).toBe(30); - expect(timeoutMinutesOn('web_shell_e2e_smoke', '')).toBe(30); + // Slow hosted browser installs took over 23 min, so reserve time in the + // 60-minute job for transcript/smoke tests and artifact upload. + expect(timeoutMinutesOn('web_shell_e2e_smoke', ECS_RUNNER)).toBe(60); + expect(timeoutMinutesOn('web_shell_e2e_smoke', HOSTED_RUNNER)).toBe(60); + expect(timeoutMinutesOn('web_shell_e2e_smoke', '')).toBe(60); }); // One helper for both "an run reaches exactly these jobs" invariants. From e5b2e8c9cd38138d466df0b235e0a7e11710d7cf Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Fri, 2 Oct 2026 08:31:15 +0800 Subject: [PATCH 29/73] fix(managed-agent): retry transient cancel-attach refusals instead of settling the Turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cancelAdmittedTurn catch settled the Turn permanently for every RuntimeException out of createOrLoad, so a transient blip (a retryable broker refusal, a daemon 5xx, a store error) terminally failed an already-admitted, still-executing Turn whose Harness side kept running. Rethrow anything that is not a non-retryable RuntimeBrokerException so the Turn stays CANCELLING and the dispatch sweep re-attempts the cancel, the pre-existing recovery path. Only a permanent refusal still settles the Turn, under its own broker code. That classification makes cancelRefusalCode's non-broker fallback unreachable — a RuntimeBrokerException code is non-null by constructor invariant — so the helper is removed rather than tested as dead code. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmuq5w6bb0f --- .../service/HarnessCoordinator.java | 29 +++++------ .../service/HarnessCoordinatorTest.java | 49 +++++++++++++++++++ 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java index 95945ff45fc..d4b54276519 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java @@ -566,16 +566,25 @@ private void cancelAdmittedTurn(String tenantId, String sessionId, session.tenantId(), session.sessionId(), session.harnessBootId() != null); } catch (RuntimeException refusal) { - // A refused attach can never reach the running Turn, and no - // sweeper re-claims a Turn whose lease the live consumer keeps - // renewing: settle the Turn the API already answered 202 for - // instead of dropping the cancel, which would leave it running - // and then settling COMPLETED. + // A transient attach failure (a retryable broker refusal, a + // daemon 5xx, a store error) recovers like any other + // cancellation failure: rethrown, it leaves the Turn + // CANCELLING for the dispatch sweep to re-attempt, instead + // of settling it permanently. + if (!(refusal instanceof RuntimeBrokerException broker) + || broker.isRetryable()) { + throw refusal; + } + // A permanent refusal can never reach the running Turn, and + // no sweeper re-claims a Turn whose lease the live consumer + // keeps renewing: settle the Turn the API already answered + // 202 for instead of dropping the cancel, which would leave + // it running and then settling COMPLETED. LOG.warn("Managed Turn cancellation was refused tenant={}" + " session={} turn={} failure={}", tenantId, sessionId, turnId, refusal.getClass().getSimpleName()); - fail(turn, cancelRefusalCode(refusal), + fail(turn, broker.getCode(), "The Hosted Harness refused the Turn cancellation."); return; } @@ -603,14 +612,6 @@ private boolean fail(TurnRecord turn, String code, String message) { return true; } - private static String cancelRefusalCode(RuntimeException refusal) { - if (refusal instanceof RuntimeBrokerException broker - && broker.getCode() != null) { - return broker.getCode(); - } - return "hosted_harness_unavailable"; - } - private boolean transientFailure(TurnRecord turn, boolean submissionAttempted, RuntimeException error) { if (!submissionAttempted diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java index 0c4c4325921..52faea523b2 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java @@ -279,6 +279,55 @@ void failsTheTurnWhenTheCancelAttachIsRefused() { } } + // A retryable refusal is transient: the Turn stays CANCELLING and the + // dispatch sweep re-attempts the cancel, so it must not be failed. + @Test + void keepsTheTurnCancellingWhenTheCancelAttachIsRetryablyRefused() { + AgentStateStore store = boundCancellingStore(); + HarnessConnector harness = mock(HarnessConnector.class); + when(harness.isWorkspaceFilesAvailable()).thenReturn(true); + when(harness.createOrLoad("tenant", "session", true)) + .thenThrow(new RuntimeBrokerException(409, "workspace_busy", + "The Workspace execution authority is busy.", true)); + HarnessCoordinator coordinator = new HarnessCoordinator(store, harness, + new HarnessEventProjector(), mock(RuntimeWarmer.class), + directExecutor(), Clock.systemUTC(), + new ManagedAgentProperties()); + try { + coordinator.cancel("tenant", "session", "turn"); + verify(harness, never()).cancel(anyString(), anyString()); + verify(store, never()).failTurn(anyString(), anyString(), + anyString(), anyString(), anyString(), anyString()); + } finally { + coordinator.close(); + } + } + + // A non-broker attach failure (the connector's approval-mode check, a + // store DataAccessException) is transient too: the cancel is re-attempted + // rather than settled with a misattributed refusal code. + @Test + void keepsTheTurnCancellingWhenTheCancelAttachThrowsANonBrokerError() { + AgentStateStore store = boundCancellingStore(); + HarnessConnector harness = mock(HarnessConnector.class); + when(harness.isWorkspaceFilesAvailable()).thenReturn(true); + when(harness.createOrLoad("tenant", "session", true)) + .thenThrow(new IllegalStateException("Hosted Harness did not" + + " confirm the Session approval mode")); + HarnessCoordinator coordinator = new HarnessCoordinator(store, harness, + new HarnessEventProjector(), mock(RuntimeWarmer.class), + directExecutor(), Clock.systemUTC(), + new ManagedAgentProperties()); + try { + coordinator.cancel("tenant", "session", "turn"); + verify(harness, never()).cancel(anyString(), anyString()); + verify(store, never()).failTurn(anyString(), anyString(), + anyString(), anyString(), anyString(), anyString()); + } finally { + coordinator.close(); + } + } + private static AgentStateStore boundCancellingStore() { AgentStateStore store = mock(AgentStateStore.class); when(store.findTurn("tenant", "session", "turn")).thenReturn(Optional.of( From 43be009a953e22a8ad1161c9a2e3d43ba480ee17 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Fri, 2 Oct 2026 08:31:31 +0800 Subject: [PATCH 30/73] fix(managed-agent): retire the rename command row on every answered failure A rename answered without completing retired its PENDING command row only for non-retryable broker refusals; every other failure family (a daemon 4xx/5xx, the connector's approval-mode IllegalStateException) left the row PENDING forever, and requireNoOpenOperation then wedged every later lifecycle change of the Session with 409 session_operation_active. Retire the row for any RuntimeException and keep the retryable/permanent split only for choosing the answered status. The freed key also stayed poisoned: the requested event the abandoned attempt published has a source_key derived from the key, so a same-key re-attempt collided on UNIQUE (tenant_id, session_id, source_key) and answered 500. beginSessionMutation now skips re-appending an already published requested event, matching the no-event replay it replaces. Two existing tests pinned the wedge (a 409 for a different key after an answered failure, and a replay header on the same-key retry); they now assert admission and a fresh, non-replay re-attempt. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmuq5w6bb0f --- .../service/ManagedAgentService.java | 20 ++--- .../managedagent/store/ManagedAgentStore.java | 17 +++- .../ManagedAgentServerIntegrationTest.java | 14 +-- .../ManagedSessionLifecycleTest.java | 7 +- .../ManagedWorkspaceAdmissionTest.java | 90 +++++++++++++++++++ 5 files changed, 122 insertions(+), 26 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java index 512753c95e7..6ea25f71f6a 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java @@ -282,19 +282,17 @@ public SessionMutationResult renameSession( session.harnessBootId() != null); harness.rename(tenantId, sessionId, effectiveTitle); } catch (RuntimeException error) { - // A non-retryable refusal (e.g. the Workspace authority's) - // is permanent: answer it with its own status and code - // instead of a transient 503, which would invite a fresh-key - // retry into session_operation_active on the still-PENDING - // command. + // A rename answered without completing must not leave the + // command row PENDING: nothing else clears it, and + // requireNoOpenOperation counts it, so every later rename + // with a fresh key would die in session_operation_active + // for the Session's life. A non-retryable refusal (e.g. the + // Workspace authority's) is then answered with its own + // status and code instead of a transient 503. + store.abandonSessionMutation(tenantId, RENAME, + idempotencyKey, sessionId); if (error instanceof RuntimeBrokerException refusal && !refusal.isRetryable()) { - // Retire the command row this refusal would leave - // PENDING: nothing else clears it, so every later rename - // with a fresh key would die in - // requireNoOpenOperation for the Session's life. - store.abandonSessionMutation(tenantId, RENAME, - idempotencyKey, sessionId); HttpStatus status = HttpStatus.resolve( refusal.getStatusCode()); throw new ApiException( diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java index ecaaf6a54a9..539b61ca86b 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java @@ -515,10 +515,19 @@ public SessionMutationCommand beginSessionMutation(String tenantId, long now = clock.millis(); insertCommand(tenantId, operation, idempotencyKey, requestDigest, sessionId, null, "PENDING", session.status(), now); - appendEvent(tenantId, sessionId, null, - mutationEvent(kind, "requested"), - Map.of("sessionId", sessionId), false, - mutationSource(operation, idempotencyKey, "requested"), now); + // A key abandonSessionMutation freed keeps the requested event the + // abandoned attempt already published, and the event's source_key is + // a function of the key, so re-appending it would collide on + // UNIQUE (tenant_id, session_id, source_key). Like the PENDING-row + // replay this re-attempt replaces, it appends nothing. + String requestedSource = mutationSource(operation, idempotencyKey, + "requested"); + if (!hasSourceEvent(tenantId, sessionId, requestedSource)) { + appendEvent(tenantId, sessionId, null, + mutationEvent(kind, "requested"), + Map.of("sessionId", sessionId), false, + requestedSource, now); + } return new SessionMutationCommand(sessionId, "PENDING", false); } diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentServerIntegrationTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentServerIntegrationTest.java index 09e9d5ee3ab..f107c738a6c 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentServerIntegrationTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentServerIntegrationTest.java @@ -640,7 +640,7 @@ void deletesAClosedSessionWhileTheHarnessIsUnavailable() } @Test - void retriesAPendingRenameWithTheSameIdempotencyKey() throws Exception { + void retriesAFailedRenameWithTheSameIdempotencyKey() throws Exception { String tenant = "tenant-rename-retry-" + UUID.randomUUID(); MvcResult created = mvc.perform(post("/v1/agents/sessions") .header(TenantContextFilter.HEADER, tenant) @@ -661,15 +661,17 @@ void retriesAPendingRenameWithTheSameIdempotencyKey() throws Exception { .andExpect(jsonPath("$.error.code") .value("hosted_harness_unavailable")); + // The answered failure retired its command row, so a different key + // is admitted instead of wedging on session_operation_active. mvc.perform(patch("/v1/agents/sessions/{id}", sessionId) .header(TenantContextFilter.HEADER, tenant) .header("Idempotency-Key", "another-rename") .contentType(MediaType.APPLICATION_JSON) .content("{\"title\":\"blocked\"}")) - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.error.code") - .value("session_operation_active")); + .andExpect(status().isOk()); + // The failed key stays free to re-attempt the mutation: the retired + // row leaves nothing to replay, so the retry performs the rename. mvc.perform(patch("/v1/agents/sessions/{id}", sessionId) .header(TenantContextFilter.HEADER, tenant) .header("Idempotency-Key", "rename-retry") @@ -677,7 +679,7 @@ void retriesAPendingRenameWithTheSameIdempotencyKey() throws Exception { .content("{\"title\":\"retry title\"}")) .andExpect(status().isOk()) .andExpect(header().string("X-Qwen-Idempotent-Replay", - "true")) + "false")) .andExpect(jsonPath("$.metadata.title") .value("retry title")); @@ -685,7 +687,7 @@ void retriesAPendingRenameWithTheSameIdempotencyKey() throws Exception { .getResponse().getContentAsString()).get("data"); assertThat(events).filteredOn(event -> "session.updated".equals( event.get("type").asText())) - .hasSize(1); + .hasSize(2); } @Test diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java index 22bc47e5c52..2f3884d931d 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java @@ -380,11 +380,8 @@ void allowsOneLifecycleChangeAtATime() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content("{\"title\":\"pending\"}")) .andExpect(status().isServiceUnavailable()); - lifecycle(post("/v1/agents/sessions/{id}/close", sessionId), tenant, - "close") - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.error.code") - .value("session_operation_active")); + // The answered failure retired its command row, so the same key + // re-attempts the rename instead of finding the Session wedged. mvc.perform(patch("/v1/agents/sessions/{id}", sessionId) .header(TENANT, tenant) .header("Idempotency-Key", "rename") diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java index 4b67dc4a004..cff8058363e 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java @@ -3,12 +3,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.catchThrowable; +import static org.mockito.Mockito.mock; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.alibaba.qwen.code.daemon.DaemonHttpException; import com.alibaba.qwen.code.managedagent.api.ApiException; import com.alibaba.qwen.code.managedagent.api.ApiModels.InputBlock; import com.alibaba.qwen.code.managedagent.api.AuthenticatedTenantActor; @@ -733,6 +735,94 @@ public void rename(String tenantId, String sessionId, + " AND session_id = ? AND command_status = 'PENDING'", Integer.class, tenant, sessionId)).isZero(); + // The freed key stays re-usable: a same-key retry re-attempts the + // mutation instead of colliding on the requested event the refused + // attempt already published. + transaction.executeWithoutResult(status -> service.renameSession( + tenant, "actor-a", "rename-1", sessionId, "first")); + assertThat(jdbc.queryForObject("SELECT title FROM" + + " managed_agent_session WHERE tenant_id = ?" + + " AND session_id = ?", String.class, tenant, + sessionId)).isEqualTo("first"); + + transaction.executeWithoutResult(status -> service.renameSession( + tenant, "actor-a", "rename-2", sessionId, "second")); + assertThat(jdbc.queryForObject("SELECT title FROM" + + " managed_agent_session WHERE tenant_id = ?" + + " AND session_id = ?", String.class, tenant, + sessionId)).isEqualTo("second"); + } + + @Test + void aRenameFailureThatIsNotABrokerRefusalStillRetiresItsCommand() { + String tenant = "tenant-" + UUID.randomUUID(); + register(tenant, "ws-a", "storage-a", + WorkspaceExecutionProfile.CONFIG_REF, + WorkspaceExecutionProfile.POLICY_REF); + grant(tenant, "ws-a", "actor-a", true); + String digest = "sha256:" + "a".repeat(64); + String sessionId = store.insertWorkspaceSessionCommand(tenant, + "actor-a", "create", digest, "qwen-code", null, null, + List.of(), null, new WorkspaceSelection("ws-a", ".")) + .sessionId(); + ManagedAgentProperties enabled = new ManagedAgentProperties(); + enabled.getHarness().setWorkspaceFilesEnabled(true); + ManagedAgentStore gated = new ManagedAgentStore(jdbc, mapper, + Clock.systemUTC(), ignored -> { + }, registry, enabled); + // A Harness that lost the Session answers the rename with a 4xx, + // which the client surfaces as a DaemonHttpException — a permanent + // failure, but not a broker refusal. + DaemonHttpException lost = mock(DaemonHttpException.class); + UnavailableHarnessConnector harness = + new UnavailableHarnessConnector() { + private int renames; + + @Override + public boolean isAvailable() { + return true; + } + + @Override + public boolean isWorkspaceFilesAvailable() { + return true; + } + + @Override + public Attachment createOrLoad(String tenantId, + String sessionId, boolean loadExisting) { + return new Attachment("boot"); + } + + @Override + public void rename(String tenantId, String sessionId, + String title) { + if (renames++ == 0) { + throw lost; + } + } + }; + ManagedAgentService service = new ManagedAgentService(gated, + new RequestDigests(), null, harness, registry); + TransactionTemplate transaction = new TransactionTemplate( + transactionManager); + + transaction.executeWithoutResult(status -> + assertThatThrownBy(() -> service.renameSession(tenant, + "actor-a", "rename-1", sessionId, "first")) + .isInstanceOfSatisfying(ApiException.class, error -> { + assertThat(error.getStatus()) + .isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(error.getCode()) + .isEqualTo("hosted_harness_unavailable"); + })); + // The answered mutation retired its command row too, so a fresh key + // is admitted instead of wedging on session_operation_active. + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM" + + " managed_agent_command WHERE tenant_id = ?" + + " AND session_id = ? AND command_status = 'PENDING'", + Integer.class, tenant, sessionId)).isZero(); + transaction.executeWithoutResult(status -> service.renameSession( tenant, "actor-a", "rename-2", sessionId, "second")); assertThat(jdbc.queryForObject("SELECT title FROM" From dba017baadc543142cfc252fbf075b1c283b3062 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 2 Oct 2026 15:41:38 +0800 Subject: [PATCH 31/73] docs(managed-agent): qualify Workspace cancel contract --- .../design/2026-09-29-hosted-public-workspace-admission.md | 7 ++++--- .../2026-09-29-hosted-public-workspace-admission.zh-CN.md | 2 +- .../openapi/managed-agent-public-api.openapi.json | 2 +- .../components/managed/generated/managed-agent-api.ts | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/design/2026-09-29-hosted-public-workspace-admission.md b/docs/design/2026-09-29-hosted-public-workspace-admission.md index 592780aa2ac..d66f9ffce83 100644 --- a/docs/design/2026-09-29-hosted-public-workspace-admission.md +++ b/docs/design/2026-09-29-hosted-public-workspace-admission.md @@ -29,9 +29,10 @@ deployment without the opt-in, keeps the existing refusal: Admitting new work requires the creator's create grant on an `ACTIVE` Workspace at the generation and storage the Session was bound to, so a re-registration refuses submit and rename before any command is written. -Cancelling only aborts work already running: the creator who can still read the -Workspace may cancel even after the create grant is revoked, the Workspace -starts draining or it is re-registered. A live cancel reuses the running +Cancelling only aborts work already running: while the deployment still enables +Workspace files, the creator who can still read the Workspace may cancel even +after the create grant is revoked, the Workspace starts draining or it is +re-registered. A live cancel reuses the running Turn's attachment without re-running the execution authority, and a cancel the Harness did not take is re-sent while the Turn is still cancelling. After each successful lease renewal, the running owner observes cancellation requested diff --git a/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md b/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md index 7a692eba500..a28cd4241e0 100644 --- a/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md +++ b/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md @@ -10,7 +10,7 @@ Hosted Read/Write/Edit 已能经过生产 Broker 和 worker 执行,但目前 G0 开放随创建会话准入的一次初始文件工具轮次,使用现有公开 REST 路由及共享 service 的 WebShell 创建适配器。发现接口仍仅广播 Workspace 绑定能力,不宣称完整的 Workspace 执行支持。G0 本身无需修改 UI;后续改动唯一的 UI 变化是为创建者开放输入框与取消控件。 -后续改动在同一开关下为会话创建者开放后续 Turn,并允许创建者取消正在运行的 Turn,由 Hosted Harness 中止该 Turn 并按原始 Runtime 身份结算。执行时每个 Turn 都按创建者的 Workspace 授权校验,因此其他 actor 以及未开启该开关的部署仍得到现有拒绝:actor 可读取该 Workspace 时为 `workspace_unavailable`,不能读取时为 `session_not_found`。创建者也可以重命名该会话。准入新工作要求创建者在 `ACTIVE` 的 Workspace 上持有创建授权,且 Workspace 的 generation 与存储与会话绑定时一致,因此重新注册后提交和改名会在写入任何命令之前被拒绝。取消只中止已在运行的工作:仍能读取该 Workspace 的创建者,即使创建授权被撤销、Workspace 进入 drain 或被重新注册,也可以取消。正常取消复用运行中 Turn 的挂接,不再执行执行授权;Harness 未接收的取消会在 Turn 仍处于取消中时重发。每次成功续租后,执行 owner 检查任意 API 副本受理的取消请求,并在 executor 中发送,避免网络等待阻塞续租调度器。投递失败按续租间隔重试。关闭、归档、删除、取消归档与 cwd 操作仍保持门禁:Runtime Broker 的 drain 只是停止为已关闭会话预热,尚无 Harness 级别的回收。 +后续改动在同一开关下为会话创建者开放后续 Turn,并允许创建者取消正在运行的 Turn,由 Hosted Harness 中止该 Turn 并按原始 Runtime 身份结算。执行时每个 Turn 都按创建者的 Workspace 授权校验,因此其他 actor 以及未开启该开关的部署仍得到现有拒绝:actor 可读取该 Workspace 时为 `workspace_unavailable`,不能读取时为 `session_not_found`。创建者也可以重命名该会话。准入新工作要求创建者在 `ACTIVE` 的 Workspace 上持有创建授权,且 Workspace 的 generation 与存储与会话绑定时一致,因此重新注册后提交和改名会在写入任何命令之前被拒绝。取消只中止已在运行的工作:部署仍开启 Workspace 文件能力时,仍能读取该 Workspace 的创建者,即使创建授权被撤销、Workspace 进入 drain 或被重新注册,也可以取消。正常取消复用运行中 Turn 的挂接,不再执行执行授权;Harness 未接收的取消会在 Turn 仍处于取消中时重发。每次成功续租后,执行 owner 检查任意 API 副本受理的取消请求,并在 executor 中发送,避免网络等待阻塞续租调度器。投递失败按续租间隔重试。关闭、归档、删除、取消归档与 cwd 操作仍保持门禁:Runtime Broker 的 drain 只是停止为已关闭会话预热,尚无 Harness 级别的回收。 ## 决策 diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json index 182ba8603e3..6cd4057aa70 100644 --- a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json +++ b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json @@ -4636,7 +4636,7 @@ "workspaceTurns": { "type": "boolean", "default": false, - "description": "True when the caller may submit later Turns of this Workspace-bound Session and rename it: the deployment enables Workspace files, the caller created the Session and currently holds Workspace read and create grants on the Workspace generation the Session was bound to, and the Session is an active qwen-code Session on the frozen execution profile. Cancelling only aborts work that is already running, so the creator may cancel a running Turn even when this is false, as long as they can still read the Workspace. False for every other caller and for unbound Sessions, which do not use it." + "description": "True when the caller may submit later Turns of this Workspace-bound Session: the deployment enables Workspace files, the caller created the Session and currently holds Workspace read and create grants on the Workspace generation and storage identity the Session was bound to, and the Session is an active qwen-code Session on the frozen execution profile. Cancelling only aborts work that is already running, so the creator may cancel a running Turn even when this is false, as long as they can still read the Workspace and the deployment still enables Workspace files. False for every other caller and for unbound Sessions, which do not use it." }, "tasks": { "type": "boolean" diff --git a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts index 1817064d438..f7ba7d87afe 100644 --- a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts +++ b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts @@ -477,7 +477,7 @@ export interface components { /** @default false */ actions: boolean; /** - * @description True when the caller may submit later Turns of this Workspace-bound Session and rename it: the deployment enables Workspace files, the caller created the Session and currently holds Workspace read and create grants on the Workspace generation the Session was bound to, and the Session is an active qwen-code Session on the frozen execution profile. Cancelling only aborts work that is already running, so the creator may cancel a running Turn even when this is false, as long as they can still read the Workspace. False for every other caller and for unbound Sessions, which do not use it. + * @description True when the caller may submit later Turns of this Workspace-bound Session: the deployment enables Workspace files, the caller created the Session and currently holds Workspace read and create grants on the Workspace generation and storage identity the Session was bound to, and the Session is an active qwen-code Session on the frozen execution profile. Cancelling only aborts work that is already running, so the creator may cancel a running Turn even when this is false, as long as they can still read the Workspace and the deployment still enables Workspace files. False for every other caller and for unbound Sessions, which do not use it. * @default false */ workspaceTurns?: boolean; From 7720f6dfda3df674c1ada163271add7d83da449b Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 2 Oct 2026 15:52:27 +0800 Subject: [PATCH 32/73] docs(web-shell): scope Session creation capabilities --- .../resources/openapi/managed-agent-public-api.openapi.json | 2 +- .../client/components/managed/generated/managed-agent-api.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json index 6cd4057aa70..d5dbec6e396 100644 --- a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json +++ b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json @@ -1107,7 +1107,7 @@ "tags": ["WebShell"], "operationId": "webShellCreateSession", "x-qwen-implementation-status": "implemented", - "description": "Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns and cancel its running Turns under the same opt-in, and may rename the Session; close, archive, delete, unarchive and cwd operations remain gated. Freeze selection with the original idempotency key; admission does not prove physical directory readiness.", + "description": "Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns and cancel its running Turns under the same opt-in; close, archive, delete, unarchive and cwd operations remain gated. Freeze selection with the original idempotency key; admission does not prove physical directory readiness.", "requestBody": { "required": true, "content": { diff --git a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts index f7ba7d87afe..3b724453377 100644 --- a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts +++ b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts @@ -76,7 +76,7 @@ export interface paths { }; get?: never; put?: never; - /** @description Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns and cancel its running Turns under the same opt-in, and may rename the Session; close, archive, delete, unarchive and cwd operations remain gated. Freeze selection with the original idempotency key; admission does not prove physical directory readiness. */ + /** @description Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns and cancel its running Turns under the same opt-in; close, archive, delete, unarchive and cwd operations remain gated. Freeze selection with the original idempotency key; admission does not prove physical directory readiness. */ post: operations["webShellCreateSession"]; delete?: never; options?: never; From 71e90072e96c81ae66648d7e31d8a5e4cfa218a2 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Fri, 2 Oct 2026 16:17:42 +0800 Subject: [PATCH 33/73] docs(managed-agent): state the retired-rename-command contract in the README Two README passages still described behavior this PR changed. The lifecycle paragraph promised a failed rename leaves a resumable PENDING command, but abandonSessionMutation now deletes that row, so a same-key retry is a fresh attempt answered replayed=false and the requested event the abandoned attempt published is not re-appended. The W1a paragraph still denied the public next-turn admission that the G0 section and the v1.28 OpenAPI note now describe; narrow it to the creator's later Turns under the opt-in and keep the resume clause. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmuqn1gku0t --- packages/sdk-java/managed-agent-server/README.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/README.md b/packages/sdk-java/managed-agent-server/README.md index eeaec767bb0..bfef3d843a8 100644 --- a/packages/sdk-java/managed-agent-server/README.md +++ b/packages/sdk-java/managed-agent-server/README.md @@ -186,9 +186,12 @@ never means that tools stopped. After the Hosted Harness restarts, its calls fai generation error until Java restarts too, as Turns do, and the operation waits. A Harness whose journal writes stopped after a failed commit answers every close with `503` until it restarts. A delete of a closed or archived Session needs no Harness. Archive accepts only a closed Session and completes at once; unarchive restores it to closed. Rename waits for the Harness to durably commit -`session_metadata`, and a failed rename leaves a `PENDING` command that the -same idempotency key can safely resume. One lifecycle change runs at a time. A -retry with the same key from the same actor returns the original operation. +`session_metadata`. A rename answered without completing retires its `PENDING` +command row, so the same idempotency key starts a fresh attempt instead of +resuming one and is not reported as a replay, and the `requested` event the +abandoned attempt already published is not re-appended. Only an in-flight +lifecycle change blocks another one. A retry with the same key from the same +actor returns the original operation once it has completed. Harness attachment uses strict create/load semantics: create returns `409` for an existing private Session authority, while load returns `404` for a missing @@ -566,8 +569,9 @@ history remain on their saved identities. The marker is a continuity check, not a backup or protection against a malicious same-UID writer. See the [W1 design](../../../docs/design/2026-09-29-managed-workspace-w1-recovery.md). Hosted Workspace cold-load validation is always enabled, independently of the Java mount-guard option. Omitted tool profile and Shell `captureBytes` use the saved definition; supplied values must match exactly. Saved approval settings remain pinned. Integrity checks run before new model work or Broker prepare/execute and cover retained private resources plus complete remote Shell output, including pages, segments and empty-stream seals. Preserve O2 recovery of original `results_ready`, consumed-final and `not_started` receipts. An incomplete receipt may produce a blocked ACK or original-history repair before load is refused, so refusal does not promise zero journal writes or ACKs. Restore validation uses a fixed committed cut, and continuation still requires current writer ownership and authorization. Missing old resources or unsupported recovery domains block loading. Passive Harness loading does not implement unknown-execution cleanup; use original Broker execution identities. Rollback to old binaries requires entry points to remain stopped because those binaries ignore the fence columns. Public -Workspace resume/next-turn admission still requires product-route integration; this -internal guard is not a public resume capability yet. +Workspace next-turn admission for the Session's creator under the G0 opt-in +described above has landed; public Workspace resume still requires product-route +integration, and this internal guard is not a public resume capability yet. Build the container from the repository root: From fe91f94272faf9c90c77a994ef9a70502c309a8d Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Fri, 2 Oct 2026 16:17:42 +0800 Subject: [PATCH 34/73] test(managed-agent): pin the command half of requireNoOpenOperation allowsOneLifecycleChangeAtATime held the suite's only witness for the PENDING-command conjunct, and this PR replaced those four lines because they pinned the wedge the retirement removes. Deleting the conjunct from requireNoOpenOperation now survives the whole module suite, so build a PENDING command row through the store and assert a close and a delete both answer 409 session_operation_active while no operation row is open. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmuqn1gku0t --- .../ManagedSessionLifecycleTest.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java index 2f3884d931d..59afc2dfa1d 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java @@ -19,6 +19,7 @@ import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.SealWriterRequest; import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationKind; import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationRecord; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionMutationKind; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.charset.StandardCharsets; @@ -413,6 +414,40 @@ void allowsOneLifecycleChangeAtATime() throws Exception { awaitCompleted(tenant, sessionId, closeId); } + /** + * The command half of {@code requireNoOpenOperation}: one still-PENDING + * mutation command and no open operation row is enough to refuse the next + * lifecycle change. Built through the store because an answered rename + * failure now retires its own row, so no route leaves one behind. + */ + @Test + void aPendingCommandRowAloneBlocksTheNextLifecycleChange() throws Exception { + String tenant = tenant(); + String sessionId = attachedSession(tenant); + store.beginSessionMutation(tenant, "RENAME_SESSION", "pending-command", + "digest", sessionId, SessionMutationKind.RENAME); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM" + + " managed_agent_command WHERE tenant_id = ? AND session_id =" + + " ? AND command_status = 'PENDING'", Integer.class, tenant, + sessionId)).isEqualTo(1); + // Without this the refusals below could be read as the operation + // conjunct firing instead of the command one. + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM" + + " managed_agent_operation WHERE tenant_id = ? AND session_id" + + " = ? AND state IN ('PENDING', 'RUNNING')", Integer.class, + tenant, sessionId)).isZero(); + lifecycle(post("/v1/agents/sessions/{id}/close", sessionId), tenant, + "close") + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code") + .value("session_operation_active")); + lifecycle(delete("/v1/agents/sessions/{id}", sessionId), tenant, + "delete") + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code") + .value("session_operation_active")); + } + @Test void deleteLeavesTheSharedRuntimeAndOtherSessionsAlone() throws Exception { From bf1bada97d66843738f2c4a6fcaf88434cbc1fda Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 2 Oct 2026 17:08:32 +0800 Subject: [PATCH 35/73] test(web-shell): pin the bound-Session Cancel control at the page level This PR moved the Cancel button inside the composer and made that form's visibility depend on `!summary?.workspace || workspaceTurns`. No page-level fixture was both bound and cancellable (0 of 23), so neither half of that composition was observable here: reverting the form gate, or re-adding `!summary.workspace` to the Cancel condition, left every case in the file green while a bound Session's creator lost the ability to stop a Turn they had just started. Adds one case mirroring the unbound cancel test. Measured both mutants: reverting the gate to `!summary?.workspace` and adding `!summary.workspace` to the Cancel condition each turn this case red (1 failed / 33 skipped) while the unmutated tree passes 34/34. Refs review finding R1-10. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmuqp6mdb01 --- .../managed/ManagedSessionsPage.test.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx b/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx index 5ef5d60048c..07ec4c2c9eb 100644 --- a/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx +++ b/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx @@ -651,6 +651,25 @@ describe('ManagedSessionsPage', () => { ); }); + it('lets the creator cancel a running Turn on a bound Session', async () => { + mocks.client.getSession.mockResolvedValue( + summary('bound', { + phase: 'tool_running', + workspace: { workspaceId: 'ws-a', cwdRelative: 'services/api' }, + capabilities: { canSend: false, canCancel: true, workspaceTurns: true }, + }), + ); + await render('bound'); + + await click('Cancel turn'); + + expect(mocks.client.cancel).toHaveBeenCalledWith( + 'bound', + 'p1', + expect.objectContaining({ clientId: expect.any(String) }), + ); + }); + async function click(label: string) { const button = [...container.querySelectorAll('button')].find( (item) => item.textContent === label, From f04bf98c68463e9731172934be6edb9eb4a4d434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Fri, 2 Oct 2026 18:00:53 +0800 Subject: [PATCH 36/73] fix(managed-agent): answer same-key retries from the record, retire rows on any permanent rename failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - submitTurn/renameSession: look up the recorded admission before the Workspace-admission gate, so a same-key retry of an admitted submit or a completed rename is answered with the recorded outcome even after a re-registration / revoked grant / DRAINING flip the gate off (R1-7). A PENDING rename row still falls through to beginSessionMutation, which answers it as replayed and re-drives the unfinished mutation. - renameSession: classify the retire-on-refusal branch by permanence, not by exception type — the connector's approval-mode IllegalStateException and permanent (<500) DaemonHttpException wedge the PENDING row exactly like a non-retryable broker refusal (R1-16). - OpenAPI: workspaceTurns is served on every response, so move it into WebShellSessionCapabilities.required (R1-5). Tests are written but not executed locally (no JDK 21/Maven on this host); the three new ManagedWorkspaceAdmissionTest cases go red without their fix by construction. Co-authored-by: Qwen-Coder --- .../service/ManagedAgentService.java | 58 +++++-- .../managed-agent-public-api.openapi.json | 2 +- .../ManagedWorkspaceAdmissionTest.java | 154 ++++++++++++++++++ 3 files changed, 202 insertions(+), 12 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java index d31914d1baf..2c58b8ef9e2 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java @@ -1,5 +1,6 @@ package com.alibaba.qwen.code.managedagent.service; +import com.alibaba.qwen.code.daemon.DaemonHttpException; import com.alibaba.qwen.code.daemon.SubmitHarnessTurn; import com.alibaba.qwen.code.managedagent.api.ApiException; import com.alibaba.qwen.code.managedagent.api.WorkspaceSelection; @@ -30,6 +31,7 @@ import com.alibaba.qwen.code.managedagent.store.ManagedWorkspaceRegistry; import com.alibaba.qwen.code.managedagent.store.StoreModels; import com.alibaba.qwen.code.managedagent.store.StoreModels.Admission; +import com.alibaba.qwen.code.managedagent.store.StoreModels.CommandRecord; import com.alibaba.qwen.code.managedagent.store.StoreModels.EventRecord; import com.alibaba.qwen.code.managedagent.store.StoreModels.EventPage; import com.alibaba.qwen.code.managedagent.store.StoreModels.ItemPartRecord; @@ -51,6 +53,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.function.BooleanSupplier; import java.util.regex.Pattern; @@ -208,17 +211,22 @@ public CommandAdmission submitTurn(String tenantId, String actorId, String idempotencyKey, String sessionId, List blocks) { validateIdempotencyKey(idempotencyKey); - requireSubmitter(tenantId, actorId, sessionId); requireHarness(); List> input = input(blocks, true); String requestDigest = digests.digest(Map.of( "sessionId", sessionId, "input", input)); + // Replay is a read of the recorded admission, not a re-admission: + // answer a same-key retry before the admission gate, which may now + // refuse on state that postdates the recorded admission (a + // re-registered Workspace, a revoked grant, a DRAINING registry), + // or the client can never recover the Turn it was given. Admission replay = replay(tenantId, SUBMIT, idempotencyKey, requestDigest); if (replay != null) { dispatch(tenantId, replay); return response(replay); } + requireSubmitter(tenantId, actorId, sessionId); String payloadDigest = SubmitHarnessTurn.computePayloadDigest(input); Admission admission; try { @@ -266,10 +274,30 @@ public SessionMutationResult renameSession( String tenantId, String actorId, String idempotencyKey, String sessionId, String title) { validateIdempotencyKey(idempotencyKey); - requireSubmitter(tenantId, actorId, sessionId); String effectiveTitle = validRenameTitle(title); String requestDigest = digests.digest(Map.of( "sessionId", sessionId, "title", effectiveTitle)); + // A completed rename is answered from its record before the + // admission gate: the gate may now refuse on state that postdates + // the recorded outcome, and a same-key retry must not lose it. + // A PENDING row falls through so beginSessionMutation answers it as + // replayed and the retry re-drives the unfinished mutation. + Optional recorded = store.findCommand(tenantId, + RENAME, idempotencyKey); + if (recorded.isPresent()) { + CommandRecord existing = recorded.get(); + if (!existing.requestDigest().equals(requestDigest) + || !existing.sessionId().equals(sessionId)) { + throw new ApiException(HttpStatus.CONFLICT, + "idempotency_conflict", + "The idempotency key was reused with different content."); + } + if ("COMPLETED".equals(existing.commandStatus())) { + return new SessionMutationResult<>(getPublicSession(tenantId, + sessionId), true); + } + } + requireSubmitter(tenantId, actorId, sessionId); SessionMutationCommand command = store.beginSessionMutation(tenantId, RENAME, idempotencyKey, requestDigest, sessionId, SessionMutationKind.RENAME); @@ -282,17 +310,17 @@ public SessionMutationResult renameSession( session.harnessBootId() != null); harness.rename(tenantId, sessionId, effectiveTitle); } catch (RuntimeException error) { - // A non-retryable refusal (e.g. the Workspace authority's) - // is permanent: answer it with its own status and code - // instead of a transient 503, which would invite a fresh-key - // retry into session_operation_active on the still-PENDING - // command. + // A permanent failure must retire the command row it left + // PENDING: nothing else clears it, so every later rename + // with a fresh key would die in requireNoOpenOperation for + // the Session's life. Permanence cannot be read off the + // broker type alone: the connector's approval-mode + // IllegalStateException and a permanent (<500) + // DaemonHttpException wedge the row identically. Transient + // failures (retryable broker refusals, 5xx) keep the row so + // a same-key retry recovers through it. if (error instanceof RuntimeBrokerException refusal && !refusal.isRetryable()) { - // Retire the command row this refusal would leave - // PENDING: nothing else clears it, so every later rename - // with a fresh key would die in - // requireNoOpenOperation for the Session's life. store.abandonSessionMutation(tenantId, RENAME, idempotencyKey, sessionId); HttpStatus status = HttpStatus.resolve( @@ -301,6 +329,14 @@ public SessionMutationResult renameSession( status == null ? HttpStatus.CONFLICT : status, refusal.getCode(), refusal.getMessage()); } + if (error instanceof IllegalStateException + || (error instanceof DaemonHttpException http + && http.getStatusCode() < 500)) { + store.abandonSessionMutation(tenantId, RENAME, + idempotencyKey, sessionId); + throw new ApiException(HttpStatus.CONFLICT, + "session_mutation_refused", error.getMessage()); + } throw dependencyUnavailable("hosted_harness_unavailable", "The Hosted Harness could not persist the Session title."); } diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json index e9960491057..df017476052 100644 --- a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json +++ b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json @@ -4617,7 +4617,7 @@ }, "WebShellSessionCapabilities": { "type": "object", - "required": ["tasks", "artifacts", "actions"], + "required": ["tasks", "artifacts", "actions", "workspaceTurns"], "properties": { "workspaceContext": { "type": "boolean", diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java index 0438aad3aba..4ec2602c3cf 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java @@ -10,13 +10,16 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.alibaba.qwen.code.managedagent.api.ApiException; +import com.alibaba.qwen.code.managedagent.api.ApiModels.CommandAdmission; import com.alibaba.qwen.code.managedagent.api.ApiModels.InputBlock; import com.alibaba.qwen.code.managedagent.api.AuthenticatedTenantActor; import com.alibaba.qwen.code.managedagent.api.TenantContextFilter; import com.alibaba.qwen.code.managedagent.api.WorkspaceSelection; import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector; import com.alibaba.qwen.code.managedagent.harness.HarnessConnector.Attachment; import com.alibaba.qwen.code.managedagent.harness.UnavailableHarnessConnector; +import com.alibaba.qwen.code.managedagent.service.HarnessCoordinator; import com.alibaba.qwen.code.managedagent.service.ManagedAgentService; import com.alibaba.qwen.code.managedagent.service.RequestDigests; import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore; @@ -1064,6 +1067,157 @@ void reRegistrationRefusesLaterWorkBeforeAnyCommandIsWritten() { sessionId, "turn_missing"), "turn_not_found"); } + @Test + void sameKeySubmitReplaysTheRecordedAdmissionAfterReRegistration() { + String tenant = "tenant-" + UUID.randomUUID(); + String sessionId = boundSession(tenant); + ManagedAgentService service = boundServiceWithWorkingHarness(); + + CommandAdmission first = service.submitTurn(tenant, "actor-a", + "submit-1", sessionId, + List.of(new InputBlock("text", "go"))); + assertThat(first.replayed()).isFalse(); + assertThat(first.turnId()).isNotBlank(); + + // Re-registration flips the admission gate off; the recorded + // admission must still answer the same-key retry — replay is a read + // of the record, not a re-admission. + jdbc.update("UPDATE managed_workspace_registry SET" + + " workspace_generation = workspace_generation + 1" + + " WHERE tenant_id = ?", tenant); + assertThat(service.getWebShellSession(tenant, "actor-a", sessionId) + .capabilities().workspaceTurns()).isFalse(); + + CommandAdmission second = service.submitTurn(tenant, "actor-a", + "submit-1", sessionId, + List.of(new InputBlock("text", "go"))); + assertThat(second.replayed()).isTrue(); + assertThat(second.turnId()).isEqualTo(first.turnId()); + } + + @Test + void sameKeyRenameReplaysTheRecordedOutcomeAfterReRegistration() { + String tenant = "tenant-" + UUID.randomUUID(); + String sessionId = boundSession(tenant); + ManagedAgentService service = boundServiceWithWorkingHarness(); + + var first = service.renameSession(tenant, "actor-a", "rename-1", + sessionId, "renamed title"); + assertThat(first.replayed()).isFalse(); + assertThat(first.body().title()).isEqualTo("renamed title"); + + jdbc.update("UPDATE managed_workspace_registry SET" + + " workspace_generation = workspace_generation + 1" + + " WHERE tenant_id = ?", tenant); + assertRefused(() -> service.renameSession(tenant, "actor-a", + "rename-fresh", sessionId, "other"), "workspace_unavailable"); + + var second = service.renameSession(tenant, "actor-a", "rename-1", + sessionId, "renamed title"); + assertThat(second.replayed()).isTrue(); + assertThat(second.body().title()).isEqualTo("renamed title"); + } + + @Test + void permanentNonBrokerRenameFailureRetiresItsCommandRow() { + String tenant = "tenant-" + UUID.randomUUID(); + String sessionId = boundSession(tenant); + // The connector's approval-mode IllegalStateException is a permanent + // failure that is not a RuntimeBrokerException: it must still retire + // the PENDING row, or every fresh-key rename wedges on + // session_operation_active for the Session's life. + UnavailableHarnessConnector harness = + new UnavailableHarnessConnector() { + private int attaches; + + @Override + public boolean isAvailable() { + return true; + } + + @Override + public boolean isWorkspaceFilesAvailable() { + return true; + } + + @Override + public Attachment createOrLoad(String tenantId, + String sessionId, boolean loadExisting) { + if (attaches++ == 0) { + throw new IllegalStateException( + "Hosted Harness did not confirm the Session approval mode"); + } + return new Attachment("boot"); + } + + @Override + public void rename(String tenantId, String sessionId, + String title) { + } + }; + ManagedAgentService service = boundServiceWith(harness); + + assertThatThrownBy(() -> service.renameSession(tenant, "actor-a", + "rename-1", sessionId, "first")) + .isInstanceOfSatisfying(ApiException.class, error -> { + assertThat(error.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(error.getCode()) + .isEqualTo("session_mutation_refused"); + }); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM" + + " managed_agent_command WHERE tenant_id = ? AND" + + " command_status = 'PENDING'", Integer.class, tenant)) + .isZero(); + + var retried = service.renameSession(tenant, "actor-a", "rename-2", + sessionId, "second"); + assertThat(retried.replayed()).isFalse(); + assertThat(retried.body().title()).isEqualTo("second"); + } + + private ManagedAgentService boundServiceWithWorkingHarness() { + return boundServiceWith(new UnavailableHarnessConnector() { + @Override + public boolean isAvailable() { + return true; + } + + @Override + public boolean isWorkspaceFilesAvailable() { + return true; + } + + @Override + public Attachment createOrLoad(String tenantId, String sessionId, + boolean loadExisting) { + return new Attachment("boot"); + } + + @Override + public void rename(String tenantId, String sessionId, + String title) { + } + }); + } + + private ManagedAgentService boundServiceWith(HarnessConnector harness) { + ManagedAgentProperties properties = new ManagedAgentProperties(); + properties.getHarness().setWorkspaceFilesEnabled(true); + ManagedAgentStore gated = new ManagedAgentStore(jdbc, mapper, + Clock.systemUTC(), ignored -> { + }, registry, properties); + HarnessCoordinator noopCoordinator = new HarnessCoordinator(null, + null, null, null, null, Clock.systemUTC(), + new ManagedAgentProperties()) { + @Override + public void dispatch(String tenantId, String sessionId, + String turnId) { + } + }; + return new ManagedAgentService(gated, new RequestDigests(), + noopCoordinator, harness, registry); + } + private String boundSession(String tenant) { register(tenant, "ws-a", "storage-a", WorkspaceExecutionProfile.CONFIG_REF, From 733e457ec092b7bf9311a2d658a5503fffdde331 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 2 Oct 2026 18:40:30 +0800 Subject: [PATCH 37/73] fix(managed-agent): clear rename admission when harness is disabled --- .../service/ManagedAgentService.java | 5 ++- .../ManagedSessionLifecycleTest.java | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java index 6ea25f71f6a..22bb4a7db07 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java @@ -274,10 +274,10 @@ public SessionMutationResult renameSession( RENAME, idempotencyKey, requestDigest, sessionId, SessionMutationKind.RENAME); if (!"COMPLETED".equals(command.status())) { - requireHarness(); SessionRecord session = store.requireSession(tenantId, sessionId); HarnessConnector.Attachment attachment; try { + requireHarness(); attachment = harness.createOrLoad(tenantId, sessionId, session.harnessBootId() != null); harness.rename(tenantId, sessionId, effectiveTitle); @@ -291,6 +291,9 @@ public SessionMutationResult renameSession( // status and code instead of a transient 503. store.abandonSessionMutation(tenantId, RENAME, idempotencyKey, sessionId); + if (error instanceof ApiException failure) { + throw failure; + } if (error instanceof RuntimeBrokerException refusal && !refusal.isRetryable()) { HttpStatus status = HttpStatus.resolve( diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java index 59afc2dfa1d..e9481b12841 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java @@ -6,6 +6,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -414,6 +415,39 @@ void allowsOneLifecycleChangeAtATime() throws Exception { awaitCompleted(tenant, sessionId, closeId); } + @Test + void unavailableHarnessDoesNotBlockLaterRenameOrCompletedReplay() + throws Exception { + String tenant = tenant(); + String sessionId = attachedSession(tenant); + harness.setAvailable(false); + try { + lifecycle(patch("/v1/agents/sessions/{id}", sessionId) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"offline\"}"), tenant, "offline") + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.error.code") + .value("hosted_harness_disabled")); + } finally { + harness.setAvailable(true); + } + lifecycle(patch("/v1/agents/sessions/{id}", sessionId) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"online\"}"), tenant, "online") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.metadata.title").value("online")); + harness.setAvailable(false); + try { + lifecycle(patch("/v1/agents/sessions/{id}", sessionId) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"online\"}"), tenant, "online") + .andExpect(status().isOk()) + .andExpect(header().string("X-Qwen-Idempotent-Replay", "true")); + } finally { + harness.setAvailable(true); + } + } + /** * The command half of {@code requireNoOpenOperation}: one still-PENDING * mutation command and no open operation row is enough to refuse the next From 220b0362369bf7b9187e0f62cdb1b84f886f099e Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 2 Oct 2026 19:45:16 +0800 Subject: [PATCH 38/73] fix(managed-agent): repair the two red required lanes on this head Both failures are this PR's own, not the base's: - ManagedAgentService.renameSession called CommandRecord.commandStatus(), which does not exist; the record's component is status. This is the sole cause of both Java lane failures (maven-compiler-plugin:compile, "cannot find symbol / method commandStatus()"), and lines 304 and 363 of the same file already use the correct "COMPLETED".equals(command.status()) idiom. - WebShellSessionCapabilities.workspaceTurns moved into the OpenAPI required array, but the generated TypeScript mirror was not regenerated, so the contract-drift guard in managed-agent-api.test.ts failed (expected `workspaceTurns: boolean;`, checked-in `workspaceTurns?: boolean;`). Regenerated with `node scripts/generate-managed-agent-api.mjs`; the only line that changed is the one the guard reported. Verified locally: managed-agent-api.test.ts 2/2 pass, java-managed-agent-provider.test.ts + ManagedSessionsPage.test.tsx 53/53 pass, eslint clean on the regenerated file. The Java edit could not be compiled here (no JDK 21 / Maven on this host); it is a single symbol-name correction read off the record declaration in this tree. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmuqtgxwd07 --- .../qwen/code/managedagent/service/ManagedAgentService.java | 2 +- .../client/components/managed/generated/managed-agent-api.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java index 2c58b8ef9e2..c4a920dc705 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java @@ -292,7 +292,7 @@ public SessionMutationResult renameSession( "idempotency_conflict", "The idempotency key was reused with different content."); } - if ("COMPLETED".equals(existing.commandStatus())) { + if ("COMPLETED".equals(existing.status())) { return new SessionMutationResult<>(getPublicSession(tenantId, sessionId), true); } diff --git a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts index 3b724453377..8698f13e404 100644 --- a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts +++ b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts @@ -480,7 +480,7 @@ export interface components { * @description True when the caller may submit later Turns of this Workspace-bound Session: the deployment enables Workspace files, the caller created the Session and currently holds Workspace read and create grants on the Workspace generation and storage identity the Session was bound to, and the Session is an active qwen-code Session on the frozen execution profile. Cancelling only aborts work that is already running, so the creator may cancel a running Turn even when this is false, as long as they can still read the Workspace and the deployment still enables Workspace files. False for every other caller and for unbound Sessions, which do not use it. * @default false */ - workspaceTurns?: boolean; + workspaceTurns: boolean; tasks: boolean; artifacts: boolean; }; From b9b4da46f51677758be968647e9381f6a0eb2ba6 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Fri, 2 Oct 2026 22:29:07 +0900 Subject: [PATCH 39/73] test(managed-agent): expect the closed Session status for a queued bound Turn WorkspaceSessionCloseMySqlIT (#13135) was written before this PR, when a bound Session refused every later Turn with workspace_unavailable. With Workspace files enabled this PR admits the creator's later Turns, so the Turn queued behind the close now reaches the Session status and answers session_not_active, which is what the test means to show: the close commits before the queued admission inspects the Session. --- .../qwen/code/managedagent/WorkspaceSessionCloseMySqlIT.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionCloseMySqlIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionCloseMySqlIT.java index 7d1e9e4d007..08b0a2b3a7d 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionCloseMySqlIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionCloseMySqlIT.java @@ -164,7 +164,9 @@ void closeCommitsBeforeQueuedTurnAndActionAdmissionsCanInspectTheSession() throw assertThrows(TimeoutException.class, () -> responses.get().get(100, TimeUnit.MILLISECONDS)); return first.beginWorkspaceClose(tenant, session, OWNER, ACTOR_DIGEST, "close", "digest", true); }); - assertCode(turns.get().get(5, TimeUnit.SECONDS), "workspace_unavailable"); + // With Workspace files enabled, a bound Session admits later Turns (#13112), so + // the queued Turn reaches the Session status and sees the committed close. + assertCode(turns.get().get(5, TimeUnit.SECONDS), "session_not_active"); assertCode(responses.get().get(5, TimeUnit.SECONDS), "session_inactive"); } assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_turn WHERE tenant_id = ?", Integer.class, tenant)).isZero(); From 3cd09bec5f1f9cf7da63aa2447f57362d051b281 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 2 Oct 2026 22:48:42 +0800 Subject: [PATCH 40/73] test(managed-agent): read the renamed Session title from metadata The public Session body projects the Session title into metadata.title (ManagedAgentService.publicSession); PublicSession has no title component, so the three rename assertions written against title() broke testCompile and reddened both Java lanes on this head. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-conflict/jmur1oq2c0i --- .../code/managedagent/ManagedWorkspaceAdmissionTest.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java index 4ec2602c3cf..9fd4c1b0af8 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java @@ -1104,7 +1104,8 @@ void sameKeyRenameReplaysTheRecordedOutcomeAfterReRegistration() { var first = service.renameSession(tenant, "actor-a", "rename-1", sessionId, "renamed title"); assertThat(first.replayed()).isFalse(); - assertThat(first.body().title()).isEqualTo("renamed title"); + assertThat(first.body().metadata()) + .containsEntry("title", "renamed title"); jdbc.update("UPDATE managed_workspace_registry SET" + " workspace_generation = workspace_generation + 1" @@ -1115,7 +1116,8 @@ void sameKeyRenameReplaysTheRecordedOutcomeAfterReRegistration() { var second = service.renameSession(tenant, "actor-a", "rename-1", sessionId, "renamed title"); assertThat(second.replayed()).isTrue(); - assertThat(second.body().title()).isEqualTo("renamed title"); + assertThat(second.body().metadata()) + .containsEntry("title", "renamed title"); } @Test @@ -1172,7 +1174,8 @@ public void rename(String tenantId, String sessionId, var retried = service.renameSession(tenant, "actor-a", "rename-2", sessionId, "second"); assertThat(retried.replayed()).isFalse(); - assertThat(retried.body().title()).isEqualTo("second"); + assertThat(retried.body().metadata()) + .containsEntry("title", "second"); } private ManagedAgentService boundServiceWithWorkingHarness() { From 9c0bcf41270efe85c7a5e4895a24b8e9180a4fc2 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 2 Oct 2026 23:56:46 +0800 Subject: [PATCH 41/73] fix(managed-agent): restore admission error precedence in CI --- .../qwen/code/managedagent/service/ManagedAgentService.java | 4 +++- .../code/managedagent/ManagedAgentServerIntegrationTest.java | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java index 15bc7684623..dd1cdcbbb7d 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java @@ -222,7 +222,6 @@ public CommandAdmission submitTurn(String tenantId, String actorId, String idempotencyKey, String sessionId, List blocks) { validateIdempotencyKey(idempotencyKey); - requireHarness(); List> input = input(blocks, true); String requestDigest = digests.digest(Map.of( "sessionId", sessionId, "input", input)); @@ -234,10 +233,12 @@ public CommandAdmission submitTurn(String tenantId, String actorId, Admission replay = replay(tenantId, SUBMIT, idempotencyKey, requestDigest); if (replay != null) { + requireHarness(); dispatch(tenantId, replay); return response(replay); } requireSubmitter(tenantId, actorId, sessionId); + requireHarness(); String payloadDigest = SubmitHarnessTurn.computePayloadDigest(input); Admission admission; try { @@ -285,6 +286,7 @@ public SessionMutationResult renameSession( String tenantId, String actorId, String idempotencyKey, String sessionId, String title) { validateIdempotencyKey(idempotencyKey); + requireReadableSession(tenantId, actorId, sessionId); String effectiveTitle = validRenameTitle(title); String requestDigest = digests.digest(Map.of( "sessionId", sessionId, "title", effectiveTitle)); diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentServerIntegrationTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentServerIntegrationTest.java index 09e9d5ee3ab..3add9dfa4d6 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentServerIntegrationTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentServerIntegrationTest.java @@ -35,6 +35,7 @@ import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationKind; import com.alibaba.qwen.code.managedagent.store.StoreModels.ProjectedEvent; import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionMutationKind; +import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.time.Clock; @@ -1741,7 +1742,8 @@ public void rename(String tenantId, String sessionId, String title) { renames.incrementAndGet(); if (renameFailures.getAndUpdate(value -> Math.max(0, value - 1)) > 0) { - throw new IllegalStateException("fixture rename failure"); + throw new RuntimeBrokerException(503, "fixture_rename_unavailable", + "fixture rename failure", true); } titles.put(sessionId, title); } From 03bdd6cdc88ca3bee8dc65d247a8c92efe1cd051 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 3 Oct 2026 00:14:29 +0800 Subject: [PATCH 42/73] fix(managed-agent): keep the Workspace refusal ahead of the Harness gate Both red required Java lanes fail the same six assertions on MySQL 8.4 and MariaDB (run 37022561191, jobs 110889014145 / 110889014257). Both causes are in this branch's production code; main has not touched packages/sdk-java since the merge base b3dda468f2. - submitTurn ran requireHarness() ahead of requireSubmitter(), so a Session the Workspace refuses was answered with 503 hosted_harness_disabled instead of 409 workspace_unavailable whenever the Harness is not configured. That is the four ManagedWorkspaceAdmissionTest failures (revocationHidesBoundSessionAndBlocksRetry:372, emptyBoundCreationOutsideTheProfileIsNotAdmittedForLaterTurns:663, creatorCancelsWithoutTheGrantsThatAdmitNewWork:1028, reRegistrationRefusesLaterWorkBeforeAnyCommandIsWritten:1055). Restore the order cancelTurn and renameSession already use, keeping the same-key replay lookup ahead of both gates so R1-7 still holds. - renameSession classified every IllegalStateException as a permanent failure, retiring the PENDING command row and answering 409 session_mutation_refused. Only the attach path is permanent: that is where QwenHostedHarnessConnector refuses an unconfirmed Session approval mode (lines 133 and 364). An IllegalStateException out of harness.rename() is an unreachable Harness, and main pins it as a transient 503 hosted_harness_unavailable whose row a same-key retry re-drives (ManagedSessionLifecycleTest .allowsOneLifecycleChangeAtATime:382, ManagedAgentServerIntegrationTest .retriesAPendingRenameWithTheSameIdempotencyKey:660, both through the failNextRename fixture at ManagedAgentServerIntegrationTest:1744). Track whether the attach returned and narrow the arm to that call site, which keeps permanentNonBrokerRenameFailureRetiresItsCommandRow green without widening the transient path. Not compiled or executed here: this host has no mvn and only JDK 1.8 (`which mvn` empty, `java -version` = 1.8.0_322, no ~/.m2), while the lanes run JDK 21 with surefire/failsafe. The change is confined to two statements and their guard; CI is the verifier. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-conflict/jmur4wgp80n --- .../service/ManagedAgentService.java | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java index 15bc7684623..ceeb8be3c44 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java @@ -222,7 +222,6 @@ public CommandAdmission submitTurn(String tenantId, String actorId, String idempotencyKey, String sessionId, List blocks) { validateIdempotencyKey(idempotencyKey); - requireHarness(); List> input = input(blocks, true); String requestDigest = digests.digest(Map.of( "sessionId", sessionId, "input", input)); @@ -237,7 +236,12 @@ public CommandAdmission submitTurn(String tenantId, String actorId, dispatch(tenantId, replay); return response(replay); } + // The admission gate stays ahead of the Harness gate, as it already + // does in cancelTurn and renameSession: a submitter the Workspace + // refuses is a 409 about that Workspace, not a 503 about how this + // deployment configures the Harness. requireSubmitter(tenantId, actorId, sessionId); + requireHarness(); String payloadDigest = SubmitHarnessTurn.computePayloadDigest(input); Admission admission; try { @@ -316,20 +320,26 @@ public SessionMutationResult renameSession( requireHarness(); SessionRecord session = store.requireSession(tenantId, sessionId); HarnessConnector.Attachment attachment; + boolean attached = false; try { attachment = harness.createOrLoad(tenantId, sessionId, session.harnessBootId() != null); + attached = true; harness.rename(tenantId, sessionId, effectiveTitle); } catch (RuntimeException error) { // A permanent failure must retire the command row it left // PENDING: nothing else clears it, so every later rename // with a fresh key would die in requireNoOpenOperation for // the Session's life. Permanence cannot be read off the - // broker type alone: the connector's approval-mode - // IllegalStateException and a permanent (<500) - // DaemonHttpException wedge the row identically. Transient - // failures (retryable broker refusals, 5xx) keep the row so - // a same-key retry recovers through it. + // broker type alone, nor off IllegalStateException as such: + // attaching is where the connector confirms the Session + // approval mode and an IllegalStateException from it never + // clears on its own, whereas one thrown by rename is an + // unreachable Harness and must stay a 503 that keeps the row + // for a same-key retry to re-drive. A permanent (<500) + // DaemonHttpException wedges the row from either call. + // Transient failures (retryable broker refusals, 5xx) keep + // the row so a same-key retry recovers through it. if (error instanceof RuntimeBrokerException refusal && !refusal.isRetryable()) { store.abandonSessionMutation(tenantId, RENAME, @@ -340,7 +350,7 @@ public SessionMutationResult renameSession( status == null ? HttpStatus.CONFLICT : status, refusal.getCode(), refusal.getMessage()); } - if (error instanceof IllegalStateException + if ((!attached && error instanceof IllegalStateException) || (error instanceof DaemonHttpException http && http.getStatusCode() < 500)) { store.abandonSessionMutation(tenantId, RENAME, From 8953b8ffb25da9e91b18a2f53b43432fd47580fa Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 3 Oct 2026 00:58:09 +0800 Subject: [PATCH 43/73] fix(runtime-broker): close drained lost workspace bindings --- .../workspace-session-reliable-close.md | 2 +- .../workspace-session-reliable-close.zh-CN.md | 2 +- .../managedagent/HostedPublicWorkspaceIT.java | 40 +++++++++++++++++-- .../ManagedWorkspaceAdmissionTest.java | 19 +++++++++ .../runtimebroker/RuntimeBindingRecord.java | 3 +- .../runtimebroker/RuntimeBrokerService.java | 9 ++++- .../RuntimeHarnessDrainTest.java | 31 ++++++++++++-- 7 files changed, 94 insertions(+), 12 deletions(-) diff --git a/docs/design/workspace-session-reliable-close.md b/docs/design/workspace-session-reliable-close.md index 2a06d6bdb63..458f23ec1d1 100644 --- a/docs/design/workspace-session-reliable-close.md +++ b/docs/design/workspace-session-reliable-close.md @@ -26,7 +26,7 @@ Persist a permanent tenant/Harness-Session drain fence under the existing tenant Fence probes use a nonlocking read after the placement guard, avoiding InnoDB gap locks that would block another tenant's fence insertion when no row exists. Execution admission discovers the immutable binding tenant outside the admission transaction; its first consistent read occurs after acquiring the guard, so REPEATABLE READ cannot hide a fence committed while admission was waiting. -Enumerate saved binding generations by tenant, Session isolation class, and isolation key, in bounded pages with byte-exact identities regardless of database collation. Mark them draining without authorizing current Workspace execution. Enumerate Runtime Sessions by exact binding/generation and release using saved records: provider release, activation=false acknowledgement, conditional original-holder release, then durable RELEASED. No acquire, installation, execution replay, or model call is part of close. Unknown execution or startup identity blocks completion. An unusable original worker blocks with an identity failure instead of entering generic lease recovery. A newer holder on shared storage is preserved. +Enumerate saved binding generations by tenant, Session isolation class, and isolation key, in bounded pages with byte-exact identities regardless of database collation. Mark them draining without authorizing current Workspace execution. Enumerate Runtime Sessions by exact binding/generation and release using saved records: provider release, activation=false acknowledgement, conditional original-holder release, then durable RELEASED. No acquire, installation, execution replay, or model call is part of close. Unknown execution or startup identity blocks completion. A LOST binding with no active Runtime Sessions or executions may retire through the same holder check and durable stop receipt; it remains LOST until the receipt commits RELEASED. A LOST binding with unsettled resources still requires recovery. An unusable original worker with an unreleased Session blocks with an identity failure instead of entering generic lease recovery. A newer holder on shared storage is preserved. ## Worker stop and completion diff --git a/docs/design/workspace-session-reliable-close.zh-CN.md b/docs/design/workspace-session-reliable-close.zh-CN.md index 7823e822bfe..b5158081ab6 100644 --- a/docs/design/workspace-session-reliable-close.zh-CN.md +++ b/docs/design/workspace-session-reliable-close.zh-CN.md @@ -26,7 +26,7 @@ Harness prompt 和既有 Runtime 恢复准入路由在本地 close 开始后拒 栅栏查询在取得 placement 锁后进行普通读取,避免缺失行上的 InnoDB 间隙锁阻塞其他租户插入栅栏。Execution 准入在准入事务之外读取不可变的 binding tenant;事务的首次一致性读发生在取得锁之后,避免 REPEATABLE READ 隐藏等待期间已提交的栅栏。 -按 tenant、Session isolation class 和 isolation key 分页枚举保存的 binding 代际;身份按字节精确匹配,不依赖数据库排序规则。标记 draining 时不重新授权当前 Workspace 执行。按精确 binding/generation 枚举 Runtime Sessions,使用保存的记录按顺序释放:provider release、activation=false 确认、条件释放原 holder、持久 RELEASED。close 不进行 acquire、安装、执行重放或模型调用。未知执行或启动身份阻止完成。原 worker 不可用时返回身份失败并阻塞,不进入通用租约恢复。共享存储上的新 holder 必须保留。 +按 tenant、Session isolation class 和 isolation key 分页枚举保存的 binding 代际;身份按字节精确匹配,不依赖数据库排序规则。标记 draining 时不重新授权当前 Workspace 执行。按精确 binding/generation 枚举 Runtime Sessions,使用保存的记录按顺序释放:provider release、activation=false 确认、条件释放原 holder、持久 RELEASED。close 不进行 acquire、安装、执行重放或模型调用。未知执行或启动身份阻止完成。没有活跃 Runtime Session 或 execution 的 LOST binding,可以经过同样的 holder 检查与持久停机凭据完成退休;提交凭据并进入 RELEASED 前保持 LOST。有未结算资源的 LOST binding 仍需要恢复。原 worker 不可用且仍有未释放的 Session 时返回身份失败并阻塞,不进入通用租约恢复。共享存储上的新 holder 必须保留。 ## Worker 停机与完成 diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java index ecbf73216c6..bed05275978 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java @@ -40,6 +40,8 @@ import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.CleanupMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext; @@ -83,10 +85,11 @@ void ownerAnswersHostedApprovalsThroughBothSurfaces() throws Exception { assertThat(answered).hasSize(8); } - @Test + @ParameterizedTest + @ValueSource(booleans = {false, true}) @EnabledOnOs(OS.LINUX) @Timeout(150) - void durableCloseStopsOriginalWorkersAndRetainsHistoryAndFiles() throws Exception { + void durableCloseStopsOriginalWorkersAndRetainsHistoryAndFiles(boolean crash) throws Exception { durableClose = true; runFiles(); List sessions = jdbc.queryForList("SELECT session_id FROM managed_agent_session WHERE tenant_id = ?" @@ -101,6 +104,23 @@ void durableCloseStopsOriginalWorkersAndRetainsHistoryAndFiles() throws Exceptio long pid = json.readTree(Files.readString(registration)).path("pid").asLong(); var worker = ProcessHandle.of(pid).orElseThrow(); assertThat(worker.isAlive()).isTrue(); + if (crash) { + worker.destroyForcibly(); + worker.onExit().get(5, TimeUnit.SECONDS); + if (index == 1) { + jdbc.update("UPDATE managed_workspace_registry SET config_ref = ? WHERE tenant_id = ?" + + " AND workspace_id = ?", WorkspaceExecutionProfile.CONFIG_REF, tenant, "workspace-" + index); + String failedTurn = request("POST", "/v1/agents/sessions/" + session + "/events", + Map.of("type", "agent.session.input.message", "input", + List.of(Map.of("type", "input_text", "text", "G0_AGAIN"))), + "after-crash", "actor", 202).path("turn_id").asText(); + await().atMost(Duration.ofSeconds(35)).untilAsserted(() -> assertThat(jdbc.queryForObject( + "SELECT status FROM managed_agent_turn WHERE session_id = ? AND turn_id = ?", + String.class, session, failedTurn)).isEqualTo("FAILED")); + } + assertThat(jdbc.queryForObject("SELECT binding_state FROM qwen_runtime_binding WHERE binding_id = ?", + String.class, binding)).isEqualTo(index == 0 ? "READY" : "LOST"); + } var retained = jdbc.queryForList("SELECT resource_id, sha256 FROM qwen_managed_session_resource" + " WHERE tenant_id = ? AND session_id = ? ORDER BY resource_id", tenant, session); assertThat(retained).isNotEmpty(); @@ -132,6 +152,19 @@ void durableCloseStopsOriginalWorkersAndRetainsHistoryAndFiles() throws Exceptio .resolve("child/proof.txt"))).isEqualTo("after"); assertThat(request("POST", route, body, "close", "actor", 202) .path(webShell ? "operationId" : "id").asText()).isEqualTo(operation); + if (crash) { + jdbc.update("UPDATE managed_workspace_registry SET config_ref = ? WHERE tenant_id = ?" + + " AND workspace_id = ?", WorkspaceExecutionProfile.CONFIG_REF, tenant, "workspace-" + index); + String nextSession = request("POST", "/v1/agents/sessions", + Map.of("agent_id", "qwen-code", "input", List.of(Map.of("type", "input_text", "text", "G0_FILES")), + "workspace", Map.of("workspace_id", "workspace-" + index, "cwd_relative", "child")), + "after-close-" + index, "actor", 202).path("id").asText(); + await().atMost(Duration.ofSeconds(35)).untilAsserted(() -> assertThat(jdbc.queryForObject( + "SELECT status FROM managed_agent_turn WHERE session_id = ?", String.class, nextSession)) + .isEqualTo("COMPLETED")); + assertThat(Files.readString(temporary.resolve(index == 0 ? "workspace-a" : "workspace-b") + .resolve("child/proof.txt"))).isEqualTo("after"); + } } } @@ -536,7 +569,8 @@ private void modelReply(HttpExchange exchange) throws IOException { String role = message.path("role").asText(); if ("user".equals(role) && message.path("content").toString().contains("G0_")) { results.clear(); - prompt.set(message.path("content").toString()); + String content = message.path("content").toString(); + prompt.set(content.substring(content.lastIndexOf("G0_"))); } else if ("tool".equals(role)) { results.add(message); } diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java index cff8058363e..a9f7ddbc7b5 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java @@ -665,6 +665,10 @@ public boolean isWorkspaceFilesAvailable() { // stays admitted. assertThat(enabled.getWebShellSession(tenant, "actor-a", controlId) .capabilities().workspaceTurns()).isTrue(); + ManagedAgentService disabled = new ManagedAgentService(store, + new RequestDigests(), null, new UnavailableHarnessConnector(), registry); + assertThat(disabled.getWebShellSession(tenant, "actor-a", controlId) + .capabilities().workspaceTurns()).isFalse(); } @Test @@ -735,6 +739,21 @@ public void rename(String tenantId, String sessionId, + " AND session_id = ? AND command_status = 'PENDING'", Integer.class, tenant, sessionId)).isZero(); + transaction.executeWithoutResult(status -> gated.beginSessionMutation( + tenant, "RENAME_SESSION", "rename-blocker", digest, sessionId, + SessionMutationKind.RENAME)); + transaction.executeWithoutResult(status -> + assertThatThrownBy(() -> service.renameSession(tenant, + "actor-a", "rename-1", sessionId, "first")) + .isInstanceOfSatisfying(ApiException.class, error -> + assertThat(error.getCode()).isEqualTo("session_operation_active"))); + assertThat(jdbc.queryForObject("SELECT command_status FROM managed_agent_command" + + " WHERE tenant_id = ? AND operation = 'RENAME_SESSION' AND idempotency_key = 'rename-1'", + String.class, tenant)).isEqualTo("FAILED"); + transaction.executeWithoutResult(status -> gated.completeSessionMutation( + tenant, "RENAME_SESSION", "rename-blocker", sessionId, + SessionMutationKind.RENAME, "intermediate", "boot")); + // The freed key stays re-usable: a same-key retry re-attempts the // mutation instead of colliding on the requested event the refused // attempt already published. diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBindingRecord.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBindingRecord.java index 56bc8297fa3..7ad1fdaa338 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBindingRecord.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBindingRecord.java @@ -390,7 +390,8 @@ void requireSafeReplacement(RuntimeBindingRecord replacement) { && stopEvidence != null) { throw new IllegalArgumentException("Recovery evidence cannot be overwritten"); } - if ((state == State.LOST && replacement.state != State.LOST) + if ((state == State.LOST && replacement.state != State.LOST + && !(replacement.state == State.RELEASED && replacement.drainReceipt != null)) || state == State.OPERATOR_RECOVERY && replacement.state != State.OPERATOR_RECOVERY && replacement.state != State.LOST) { diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerService.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerService.java index 18e6cff3511..f3dc5e018eb 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerService.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerService.java @@ -194,7 +194,6 @@ private CompletionStage drainBinding(RuntimeBindingRecord saved) { return CompletableFuture.completedFuture(null); } if (!saved.getRequest().isManagedContext() || !provisioner.supportsDrainedStop() - || saved.getState() == RuntimeBindingRecord.State.LOST || saved.getState() == RuntimeBindingRecord.State.OPERATOR_RECOVERY || saved.getState() == RuntimeBindingRecord.State.FAILED) { return failed(conflict("workspace_close_identity_unverified", "Original worker needs recovery")); @@ -214,8 +213,14 @@ private CompletionStage drainClaimedBinding(RuntimeBindingRecord saved) { if (claimed == null) { return failed(unavailable("runtime_close_claim_pending", "Original binding is still claimed")); } + boolean lost = claimed.getState() == RuntimeBindingRecord.State.LOST; + if (lost && (sessionRepository.countActiveByBinding(claimed.getBindingId(), claimed.getGeneration()) != 0 + || executionRepository.hasActiveByBinding(claimed.getBindingId(), claimed.getGeneration()))) { + releaseOperationQuietly(claimed.getBindingId(), claimed.getOperationGeneration()); + return failed(conflict("workspace_close_execution_unsettled", "Lost Runtime resources require recovery")); + } var draining = bindingRepository.compareAndSet(claimed, claimed.withDrainRequested(true, clock.instant()) - .withState(RuntimeBindingRecord.State.DRAINING, + .withState(lost ? RuntimeBindingRecord.State.LOST : RuntimeBindingRecord.State.DRAINING, claimed.getLease(), clock.instant())); if (draining == null) { releaseOperationQuietly(claimed.getBindingId(), claimed.getOperationGeneration()); diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeHarnessDrainTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeHarnessDrainTest.java index 72d5d5a0840..3d3aafac1b3 100644 --- a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeHarnessDrainTest.java +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeHarnessDrainTest.java @@ -183,12 +183,12 @@ void stalledDrainReleasesItsClaimAndFencesLateCompletion(String step) throws Exc } @ParameterizedTest - @ValueSource(booleans = {false, true}) - void drainingBlocksRivalStoragePlacementUntilStopIsProven(boolean jdbc) throws Exception { + @CsvSource({"false,RECOVERY_BLOCKED", "true,RECOVERY_BLOCKED", "false,LOST", "true,LOST"}) + void drainingBlocksRivalStoragePlacementUntilStopIsProven(boolean jdbc, String state) throws Exception { RuntimeBindingRepository registry = jdbc ? bindings : new InMemoryRuntimeBindingRepository(); var ready = ready(registry); var claimed = registry.claimOperation(ready.getBindingId(), "block", Duration.ofSeconds(10)); - var blocked = registry.compareAndSet(claimed, claimed.withState(RuntimeBindingRecord.State.RECOVERY_BLOCKED, + var blocked = registry.compareAndSet(claimed, claimed.withState(RuntimeBindingRecord.State.valueOf(state), claimed.getLease(), Instant.now())); assertNotNull(blocked); var binding = registry.releaseOperation(blocked.getBindingId(), "block", blocked.getOperationGeneration()); @@ -203,7 +203,8 @@ void drainingBlocksRivalStoragePlacementUntilStopIsProven(boolean jdbc) throws E provisioner, transport, registry, sessions, executions, "drainer", Duration.ofSeconds(2), Duration.ofSeconds(2))) { service.requestHarnessDrain("tenant", "harness"); var close = service.drainHarnessSession("tenant", "harness").toCompletableFuture(); - assertEquals(RuntimeBindingRecord.State.DRAINING, registry.findById(binding.getBindingId()).getState()); + assertEquals("LOST".equals(state) ? RuntimeBindingRecord.State.LOST : RuntimeBindingRecord.State.DRAINING, + registry.findById(binding.getBindingId()).getState()); assertEquals("runtime_placement_recovery_required", assertThrows(RuntimeBrokerException.class, () -> registry.findOrCreate(rival)).getCode()); registry.findOrCreate(new RuntimeProvisionRequest(rivalScope, "unrelated", "local-process", "other-storage")); @@ -266,6 +267,28 @@ void oldProvisioningReplyCannotLaunchAfterAnotherBrokerRetiresItsIntent() throws } } + @Test + void lostBindingWithAnUnreleasedSessionStillRequiresRecovery() throws Exception { + var binding = ready(); + var session = bindings.admitSession(sessions, candidate(binding, "one")); + var claimed = bindings.claimOperation(binding.getBindingId(), "setup", Duration.ofSeconds(10)); + var lost = bindings.compareAndSet(claimed, claimed.withState(RuntimeBindingRecord.State.LOST, + claimed.getLease(), Instant.now())); + bindings.releaseOperation(lost.getBindingId(), "setup", lost.getOperationGeneration()); + try (var service = service()) { + service.requestHarnessDrain("tenant", "harness"); + var failure = assertThrows(ExecutionException.class, + () -> service.drainHarnessSession("tenant", "harness").toCompletableFuture().get()); + assertEquals("workspace_close_execution_unsettled", + assertInstanceOf(RuntimeBrokerException.class, failure.getCause()).getCode()); + assertTrue(sessions.findById(scope, session.getRuntimeSessionId()).isActive()); + assertEquals(RuntimeBindingRecord.State.LOST, bindings.findById(binding.getBindingId()).getState()); + assertNull(bindings.findById(binding.getBindingId()).getDrainReceipt()); + assertEquals(0, transport.releases); + assertEquals(0, provisioner.stops); + } + } + @Test void unknownExecutionBlocksReleaseAndStop() throws Exception { var binding = ready(); From 3363fff45a1fe136bcf46b94a94976bc107ebfe5 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 3 Oct 2026 12:24:59 +0800 Subject: [PATCH 44/73] docs(managed-agent): state the cancel rule and refusal codes the code enforces The public createSession description bound cancel to the submit-grade conditions that requireCanceller deliberately does not check (create grant, ACTIVE registry row, active undeleted frozen-profile Session), so an integrator gating its Cancel control on them would withhold a cancel the server accepts and could not abort a runaway Turn on a draining or re-registered Workspace. Split the sentence: submit and rename keep the submit-grade rule, now also naming the generation/storage conjunct, and cancel states the narrower creator-plus-Workspace-read rule. Name the 404 session_not_found that requireLegacyWorkspace answers when the caller lost Workspace read, next to the 409 already documented. Drop the rename promise from webShellCreateSession - the WebShell surface exposes no rename route, rename is updateSession on Public Sessions only - re-landing the removal already made at 7720f6dfda. Add the ACTIVE registry-state conjunct to the workspaceTurns capability so it matches maySubmitWorkspaceTurn and the "same rule" claim beside it. The generated WebShell mirror is updated to match; the generator imports openapi-typescript, which is unavailable without node_modules here. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmurtwo5e1r --- .../resources/openapi/managed-agent-public-api.openapi.json | 6 +++--- .../components/managed/generated/managed-agent-api.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json index 69668abbe7b..7b17ab3962f 100644 --- a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json +++ b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json @@ -184,7 +184,7 @@ "tags": ["Public Sessions"], "operationId": "createSession", "x-qwen-implementation-status": "implemented", - "description": "Workspace selection is persisted with the original actor-scoped creation receipt. With the deployment's G0 file admission opt-in, qwen-code Sessions in an authorized, preregistered Workspace may include initial input under the fixed preapproved file-tool profile. Public callers cannot select the profile. Otherwise Workspace creation must have empty input. The Session creator may submit later Turns and cancel its running Turns under the same opt-in, and may rename the Session, while the creator currently holds Workspace read and create grants on a registry row whose state is ACTIVE and the Session is an active, undeleted qwen-code Session on the frozen execution profile; Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. This surface has no per-caller capability flag for later Turns, so callers must handle 409 workspace_unavailable. Check replay before resolving defaults; a durable binding is not proof of Runtime readiness.", + "description": "Workspace selection is persisted with the original actor-scoped creation receipt. With the deployment's G0 file admission opt-in, qwen-code Sessions in an authorized, preregistered Workspace may include initial input under the fixed preapproved file-tool profile. Public callers cannot select the profile. Otherwise Workspace creation must have empty input. The Session creator may submit later Turns and rename the Session under the same opt-in, while the creator currently holds Workspace read and create grants on a registry row whose state is ACTIVE and still carries the Workspace generation and storage identity the Session was bound to, and the Session is an active, undeleted qwen-code Session on the frozen execution profile. Cancelling only aborts work that is already running, so the creator may cancel a running Turn under the same opt-in while they can still read the Workspace, even after create authority is revoked, the registry row leaves ACTIVE or the Workspace is re-registered; Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. This surface has no per-caller capability flag for later Turns, so callers must handle a refused later Turn: 409 workspace_unavailable, or 404 session_not_found when the caller no longer holds Workspace read. Check replay before resolving defaults; a durable binding is not proof of Runtime readiness.", "parameters": [ { "$ref": "#/components/parameters/IdempotencyKey" @@ -1129,7 +1129,7 @@ "tags": ["WebShell"], "operationId": "webShellCreateSession", "x-qwen-implementation-status": "implemented", - "description": "Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns and rename the Session under the same opt-in with current read and create grants on the bound ACTIVE Workspace generation and storage. Cancellation of running work requires the creator’s read grant and the opt-in, even after create authority is revoked; Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. The per-caller workspaceTurns capability on this surface advertises the same rule. Freeze selection with the original idempotency key; admission does not prove physical directory readiness.", + "description": "Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns under the same opt-in with current read and create grants on the bound ACTIVE Workspace generation and storage. Cancellation of running work requires the creator’s read grant and the opt-in, even after create authority is revoked; Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. The per-caller workspaceTurns capability on this surface advertises the same rule. Freeze selection with the original idempotency key; admission does not prove physical directory readiness.", "requestBody": { "required": true, "content": { @@ -4744,7 +4744,7 @@ "workspaceTurns": { "type": "boolean", "default": false, - "description": "True when the caller may submit later Turns of this Workspace-bound Session: the deployment enables Workspace files, the caller created the Session and currently holds Workspace read and create grants on the Workspace generation and storage identity the Session was bound to, and the Session is an active qwen-code Session on the frozen execution profile. Cancelling only aborts work that is already running, so the creator may cancel a running Turn even when this is false, as long as they can still read the Workspace and the deployment still enables Workspace files. False for every other caller and for unbound Sessions, which do not use it." + "description": "True when the caller may submit later Turns of this Workspace-bound Session: the deployment enables Workspace files, the caller created the Session and currently holds Workspace read and create grants on a registry row whose state is ACTIVE and still carries the Workspace generation and storage identity the Session was bound to, and the Session is an active, undeleted qwen-code Session on the frozen execution profile. Cancelling only aborts work that is already running, so the creator may cancel a running Turn even when this is false, as long as they can still read the Workspace and the deployment still enables Workspace files. False for every other caller and for unbound Sessions, which do not use it." }, "tasks": { "type": "boolean" diff --git a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts index c7624c342e9..7507098ad12 100644 --- a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts +++ b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts @@ -76,7 +76,7 @@ export interface paths { }; get?: never; put?: never; - /** @description Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns and rename the Session under the same opt-in with current read and create grants on the bound ACTIVE Workspace generation and storage. Cancellation of running work requires the creator’s read grant and the opt-in, even after create authority is revoked; Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. The per-caller workspaceTurns capability on this surface advertises the same rule. Freeze selection with the original idempotency key; admission does not prove physical directory readiness. */ + /** @description Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns under the same opt-in with current read and create grants on the bound ACTIVE Workspace generation and storage. Cancellation of running work requires the creator’s read grant and the opt-in, even after create authority is revoked; Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. The per-caller workspaceTurns capability on this surface advertises the same rule. Freeze selection with the original idempotency key; admission does not prove physical directory readiness. */ post: operations["webShellCreateSession"]; delete?: never; options?: never; @@ -497,7 +497,7 @@ export interface components { /** @default false */ actions: boolean; /** - * @description True when the caller may submit later Turns of this Workspace-bound Session: the deployment enables Workspace files, the caller created the Session and currently holds Workspace read and create grants on the Workspace generation and storage identity the Session was bound to, and the Session is an active qwen-code Session on the frozen execution profile. Cancelling only aborts work that is already running, so the creator may cancel a running Turn even when this is false, as long as they can still read the Workspace and the deployment still enables Workspace files. False for every other caller and for unbound Sessions, which do not use it. + * @description True when the caller may submit later Turns of this Workspace-bound Session: the deployment enables Workspace files, the caller created the Session and currently holds Workspace read and create grants on a registry row whose state is ACTIVE and still carries the Workspace generation and storage identity the Session was bound to, and the Session is an active, undeleted qwen-code Session on the frozen execution profile. Cancelling only aborts work that is already running, so the creator may cancel a running Turn even when this is false, as long as they can still read the Workspace and the deployment still enables Workspace files. False for every other caller and for unbound Sessions, which do not use it. * @default false */ workspaceTurns: boolean; From 2f0d3892e0648695b7320367cc86381fd56ca9f4 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 3 Oct 2026 18:14:48 +0900 Subject: [PATCH 45/73] fix(managed-agent): answer bound replays only to the creator and keep Cancel reachable R3-1: submit and rename now check the creator's durable creation receipt before the replay lookup, so a reader who did not create a bound Session neither replays the creator's admission nor learns which keys exist. The receipt survives revocation, draining and re-registration, so the creator's own same-key retry still replays. R3-2: cancel is no longer tied to workspaceTurns in the WebShell provider, and the Cancel button renders outside the composer when the composer is hidden. Send keeps its gate; the server refuses a cancel from anyone else. R3-3, R3-4: scope the published cancel claim to a resident attachment (after a restart or takeover the owner re-attaches through Workspace execution authority and the Turn may stay cancelling while it refuses), and align the README's G0 twin with the later-Turn rule. --- ...09-29-hosted-public-workspace-admission.md | 4 +- ...hosted-public-workspace-admission.zh-CN.md | 2 +- .../sdk-java/managed-agent-server/README.md | 11 +++- .../service/HarnessCoordinator.java | 4 +- .../service/ManagedAgentService.java | 15 +++++ .../managed-agent-public-api.openapi.json | 4 +- .../ManagedWorkspaceAdmissionTest.java | 24 ++++++++ .../managed/ManagedSessionsPage.test.tsx | 43 +++++++++----- .../managed/ManagedSessionsPage.tsx | 35 ++++++----- .../managed/generated/managed-agent-api.ts | 2 +- .../java-managed-agent-provider.test.ts | 58 +++++++++++-------- .../managed/java-managed-agent-provider.ts | 9 ++- 12 files changed, 144 insertions(+), 67 deletions(-) diff --git a/docs/design/2026-09-29-hosted-public-workspace-admission.md b/docs/design/2026-09-29-hosted-public-workspace-admission.md index c8b2ed45be0..9ce33f6933c 100644 --- a/docs/design/2026-09-29-hosted-public-workspace-admission.md +++ b/docs/design/2026-09-29-hosted-public-workspace-admission.md @@ -33,7 +33,9 @@ Cancelling only aborts work already running: while the deployment still enables Workspace files, the creator who can still read the Workspace may cancel even after the create grant is revoked, the Workspace starts draining or it is re-registered. A live cancel reuses the running -Turn's attachment without re-running the execution authority, and a cancel the +Turn's resident attachment without re-running the execution authority; after a +restart or takeover the owner re-attaches through that authority, so while it +refuses, the Turn may stay cancelling. A cancel the Harness did not take is re-sent while the Turn is still cancelling. After each successful lease renewal, the running owner observes cancellation requested through any API replica and sends it on the executor, keeping network waits diff --git a/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md b/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md index 830dd5c2abc..d36b8e5881b 100644 --- a/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md +++ b/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md @@ -10,7 +10,7 @@ Hosted Read/Write/Edit 已能经过生产 Broker 和 worker 执行,但目前 G0 开放随创建会话准入的一次初始文件工具轮次,使用现有公开 REST 路由及共享 service 的 WebShell 创建适配器。发现接口仍仅广播 Workspace 绑定能力,不宣称完整的 Workspace 执行支持。G0 本身无需修改 UI;后续改动唯一的 UI 变化是为创建者开放输入框与取消控件。 -后续改动在同一开关下为会话创建者开放后续 Turn,并允许创建者取消正在运行的 Turn,由 Hosted Harness 中止该 Turn 并按原始 Runtime 身份结算。执行时每个 Turn 都按创建者的 Workspace 授权校验,因此其他 actor 以及未开启该开关的部署仍得到现有拒绝:actor 可读取该 Workspace 时为 `workspace_unavailable`,不能读取时为 `session_not_found`。创建者也可以重命名该会话。准入新工作要求创建者在 `ACTIVE` 的 Workspace 上持有创建授权,且 Workspace 的 generation 与存储与会话绑定时一致,因此重新注册后提交和改名会在写入任何命令之前被拒绝。取消只中止已在运行的工作:部署仍开启 Workspace 文件能力时,仍能读取该 Workspace 的创建者,即使创建授权被撤销、Workspace 进入 drain 或被重新注册,也可以取消。正常取消复用运行中 Turn 的挂接,不再执行执行授权;Harness 未接收的取消会在 Turn 仍处于取消中时重发。每次成功续租后,执行 owner 检查任意 API 副本受理的取消请求,并在 executor 中发送,避免网络等待阻塞续租调度器。投递失败按续租间隔重试。工作区关闭通过独立的关闭能力及生命周期准入控制;归档、删除与取消归档遵循可靠工作区关闭后的独立保留能力;cwd 操作仍保持门禁。 +后续改动在同一开关下为会话创建者开放后续 Turn,并允许创建者取消正在运行的 Turn,由 Hosted Harness 中止该 Turn 并按原始 Runtime 身份结算。执行时每个 Turn 都按创建者的 Workspace 授权校验,因此其他 actor 以及未开启该开关的部署仍得到现有拒绝:actor 可读取该 Workspace 时为 `workspace_unavailable`,不能读取时为 `session_not_found`。创建者也可以重命名该会话。准入新工作要求创建者在 `ACTIVE` 的 Workspace 上持有创建授权,且 Workspace 的 generation 与存储与会话绑定时一致,因此重新注册后提交和改名会在写入任何命令之前被拒绝。取消只中止已在运行的工作:部署仍开启 Workspace 文件能力时,仍能读取该 Workspace 的创建者,即使创建授权被撤销、Workspace 进入 drain 或被重新注册,也可以取消。正常取消复用运行中 Turn 驻留在内存中的挂接,不再执行执行授权;重启或接管后 owner 需经执行授权重新挂接,授权拒绝期间 Turn 可能一直处于取消中。Harness 未接收的取消会在 Turn 仍处于取消中时重发。每次成功续租后,执行 owner 检查任意 API 副本受理的取消请求,并在 executor 中发送,避免网络等待阻塞续租调度器。投递失败按续租间隔重试。工作区关闭通过独立的关闭能力及生命周期准入控制;归档、删除与取消归档遵循可靠工作区关闭后的独立保留能力;cwd 操作仍保持门禁。 ## 决策 diff --git a/packages/sdk-java/managed-agent-server/README.md b/packages/sdk-java/managed-agent-server/README.md index e414c61be0e..7b179e12947 100644 --- a/packages/sdk-java/managed-agent-server/README.md +++ b/packages/sdk-java/managed-agent-server/README.md @@ -359,8 +359,10 @@ not a filesystem sandbox. Later Turns may be submitted by the Session's creator under the same opt-in while they can still read and create in the Workspace (the per-caller `workspaceTurns` capability flag reflects the caller's current -grants and the Workspace registry's `ACTIVE` state), and the creator may cancel -the Session's running Turns and rename the Session. Workspace close follows +grants, the Workspace registry's `ACTIVE` state and the Workspace generation the +Session was bound to), and the creator may rename the Session. The creator may +also cancel a running Turn while they can still read the Workspace, under the +cancel rule below. Workspace close follows its separate close capability and lifecycle admission. Archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close. Cwd operations and broad Workspace capability advertisement remain gated. Shell and in-flight recovery are separate slices. @@ -498,7 +500,10 @@ registry's `ACTIVE` state and the Workspace generation the Session was bound to); the creator may also rename the Session. Cancelling aborts work that is already running, so the creator may cancel a running Turn while they can still read the Workspace, even after their create grant is revoked, the Workspace -starts draining or it is re-registered. Later Turns run +starts draining or it is re-registered. A live cancel reuses the owner's +resident Harness attachment; after a restart or takeover the owner re-attaches +through Workspace execution authority, so while that authority refuses, the +Turn may stay cancelling. Later Turns run under the creator's Workspace grants, so any other actor keeps the existing refusal: `workspace_unavailable` when the actor can read the Workspace, `session_not_found` when they cannot. Public close follows its separate close diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java index 520a30e2fc9..5c28c3921fa 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java @@ -561,7 +561,9 @@ private void cancelAdmittedTurn(String tenantId, String sessionId, // An admitted Turn's boot is already bound, so the cancel needs no // attach: attaching re-runs the Workspace authorization, and // aborting running work must not depend on the grants that admit - // new work. harness.cancel reuses the running Turn's attachment. + // new work. harness.cancel reuses the running Turn's attachment + // while it is resident; after a restart or takeover it re-attaches + // through that authority and keeps retrying while it refuses. if (session.harnessBootId() != null && store.bindHarness(tenantId, sessionId, turnId, owner, session.harnessBootId())) { harness.cancel(session.tenantId(), session.sessionId()); diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java index 284bcb25345..998afafa7fd 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java @@ -225,6 +225,7 @@ public CommandAdmission submitTurn(String tenantId, String actorId, List blocks) { validateIdempotencyKey(idempotencyKey); requireReadableSession(tenantId, actorId, sessionId); + requireBoundCreator(tenantId, actorId, sessionId); List> input = input(blocks, true); String requestDigest = digests.digest(Map.of( "sessionId", sessionId, "input", input)); @@ -290,6 +291,7 @@ public SessionMutationResult renameSession( String title) { validateIdempotencyKey(idempotencyKey); requireReadableSession(tenantId, actorId, sessionId); + requireBoundCreator(tenantId, actorId, sessionId); String effectiveTitle = validRenameTitle(title); String requestDigest = digests.digest(Map.of( "sessionId", sessionId, "title", effectiveTitle)); @@ -783,6 +785,19 @@ private void requireSubmitter(String tenantId, String actorId, } } + // Replay skips the admission gate, so a bound Session's recorded + // admission answers only its creator. The creation receipt survives a + // revoked grant, a draining Workspace and a re-registration, so the + // creator's own same-key retry still replays. + private void requireBoundCreator(String tenantId, String actorId, + String sessionId) { + SessionRecord session = store.requireSession(tenantId, sessionId); + if (session.workspace() != null + && !workspaces.createdSession(tenantId, actorId, sessionId)) { + requireLegacyWorkspace(session, actorId); + } + } + // Cancelling aborts work that is already running, so it needs only what // identifies the creator, not the grants that admit new work: the // creator who can still read the Workspace may cancel while can_create is diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json index 7b17ab3962f..00ce033ebb5 100644 --- a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json +++ b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json @@ -184,7 +184,7 @@ "tags": ["Public Sessions"], "operationId": "createSession", "x-qwen-implementation-status": "implemented", - "description": "Workspace selection is persisted with the original actor-scoped creation receipt. With the deployment's G0 file admission opt-in, qwen-code Sessions in an authorized, preregistered Workspace may include initial input under the fixed preapproved file-tool profile. Public callers cannot select the profile. Otherwise Workspace creation must have empty input. The Session creator may submit later Turns and rename the Session under the same opt-in, while the creator currently holds Workspace read and create grants on a registry row whose state is ACTIVE and still carries the Workspace generation and storage identity the Session was bound to, and the Session is an active, undeleted qwen-code Session on the frozen execution profile. Cancelling only aborts work that is already running, so the creator may cancel a running Turn under the same opt-in while they can still read the Workspace, even after create authority is revoked, the registry row leaves ACTIVE or the Workspace is re-registered; Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. This surface has no per-caller capability flag for later Turns, so callers must handle a refused later Turn: 409 workspace_unavailable, or 404 session_not_found when the caller no longer holds Workspace read. Check replay before resolving defaults; a durable binding is not proof of Runtime readiness.", + "description": "Workspace selection is persisted with the original actor-scoped creation receipt. With the deployment's G0 file admission opt-in, qwen-code Sessions in an authorized, preregistered Workspace may include initial input under the fixed preapproved file-tool profile. Public callers cannot select the profile. Otherwise Workspace creation must have empty input. The Session creator may submit later Turns and rename the Session under the same opt-in, while the creator currently holds Workspace read and create grants on a registry row whose state is ACTIVE and still carries the Workspace generation and storage identity the Session was bound to, and the Session is an active, undeleted qwen-code Session on the frozen execution profile. Cancelling only aborts work that is already running, so the creator may cancel a running Turn under the same opt-in while they can still read the Workspace, even after create authority is revoked, the registry row leaves ACTIVE or the Workspace is re-registered (a live cancel reuses the owner's resident Harness attachment; after a restart or takeover the owner re-attaches through Workspace execution authority, so while that authority refuses, the Turn may stay cancelling); Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. This surface has no per-caller capability flag for later Turns, so callers must handle a refused later Turn: 409 workspace_unavailable, or 404 session_not_found when the caller no longer holds Workspace read. Check replay before resolving defaults; a durable binding is not proof of Runtime readiness.", "parameters": [ { "$ref": "#/components/parameters/IdempotencyKey" @@ -1129,7 +1129,7 @@ "tags": ["WebShell"], "operationId": "webShellCreateSession", "x-qwen-implementation-status": "implemented", - "description": "Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns under the same opt-in with current read and create grants on the bound ACTIVE Workspace generation and storage. Cancellation of running work requires the creator’s read grant and the opt-in, even after create authority is revoked; Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. The per-caller workspaceTurns capability on this surface advertises the same rule. Freeze selection with the original idempotency key; admission does not prove physical directory readiness.", + "description": "Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns under the same opt-in with current read and create grants on the bound ACTIVE Workspace generation and storage. Cancellation of running work requires the creator’s read grant and the opt-in, even after create authority is revoked (a live cancel reuses the owner's resident Harness attachment; after a restart or takeover the owner re-attaches through Workspace execution authority, so while that authority refuses, the Turn may stay cancelling); Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. The per-caller workspaceTurns capability on this surface advertises the same rule. Freeze selection with the original idempotency key; admission does not prove physical directory readiness.", "requestBody": { "required": true, "content": { diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java index 05147fab6da..93f8c54748b 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java @@ -1192,6 +1192,30 @@ void sameKeySubmitReplayRequiresWorkspaceReadAccess() { "submit-1", sessionId, input), "session_not_found"); } + @Test + void sameKeyReplayAnswersOnlyTheCreator() { + String tenant = "tenant-" + UUID.randomUUID(); + String sessionId = boundSession(tenant); + grant(tenant, "ws-a", "actor-b", false); + ManagedAgentService service = boundServiceWithWorkingHarness(); + List input = List.of(new InputBlock("text", "go")); + service.submitTurn(tenant, "actor-a", "submit-1", sessionId, input); + service.renameSession(tenant, "actor-a", "rename-1", sessionId, + "renamed title"); + + // A reader who did not create the Session neither replays the + // creator's admissions nor learns which keys exist. + assertRefused(() -> service.submitTurn(tenant, "actor-b", + "submit-1", sessionId, input), "workspace_unavailable"); + assertRefused(() -> service.submitTurn(tenant, "actor-b", + "submit-1", sessionId, + List.of(new InputBlock("text", "other"))), + "workspace_unavailable"); + assertRefused(() -> service.renameSession(tenant, "actor-b", + "rename-1", sessionId, "renamed title"), + "workspace_unavailable"); + } + @Test void sameKeySubmitReplaysTheRecordedAdmissionAfterReRegistration() { String tenant = "tenant-" + UUID.randomUUID(); diff --git a/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx b/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx index c99feb52469..b54c613716e 100644 --- a/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx +++ b/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx @@ -862,24 +862,35 @@ describe('ManagedSessionsPage', () => { ); }); - it('lets the creator cancel a running Turn on a bound Session', async () => { - mocks.client.getSession.mockResolvedValue( - summary('bound', { - phase: 'tool_running', - workspace: { workspaceId: 'ws-a', cwdRelative: 'services/api' }, - capabilities: { canSend: false, canCancel: true, workspaceTurns: true }, - }), - ); - await render('bound'); + it.each([true, false])( + 'lets the creator cancel a running bound Turn when workspaceTurns is %s', + async (workspaceTurns) => { + mocks.client.getSession.mockResolvedValue( + summary('bound', { + phase: 'tool_running', + workspace: { workspaceId: 'ws-a', cwdRelative: 'services/api' }, + capabilities: { + canSend: false, + canCancel: true, + ...(workspaceTurns ? { workspaceTurns: true } : {}), + }, + }), + ); + await render('bound'); - await click('Cancel turn'); + // Without workspaceTurns the composer stays hidden, but Cancel does not. + expect(container.querySelector('textarea') !== null).toBe( + workspaceTurns, + ); + await click('Cancel turn'); - expect(mocks.client.cancel).toHaveBeenCalledWith( - 'bound', - 'p1', - expect.objectContaining({ clientId: expect.any(String) }), - ); - }); + expect(mocks.client.cancel).toHaveBeenCalledWith( + 'bound', + 'p1', + expect.objectContaining({ clientId: expect.any(String) }), + ); + }, + ); async function click(label: string) { const button = [...container.querySelectorAll('button')].find( diff --git a/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx b/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx index c629ecf096a..05cc654751b 100644 --- a/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx +++ b/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx @@ -366,6 +366,22 @@ function ManagedSessionsContent({ const active = summary && !['created', 'completed', 'failed', 'cancelled'].includes(summary.phase); + // Cancel authority differs from submit authority: the creator may stop a + // running bound Turn after the Workspace stops admitting new work, so the + // control is not tied to the composer and the server's 409 is the gate. + const cancelButton = + cancellationEnabled && + summary?.capabilities.canCancel && + summary.activeTurnId ? ( + + ) : null; return (
@@ -646,18 +662,7 @@ function ManagedSessionsContent({ {t('managed.newRequired')} )} - {cancellationEnabled && - summary?.capabilities.canCancel && - summary.activeTurnId && ( - - )} + {cancelButton}
- ) : null} + ) : ( + cancelButton && ( +
{cancelButton}
+ ) + )} {outputTarget && outputTarget.sessionId === sessionId && sessionId && diff --git a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts index 7507098ad12..daea259be01 100644 --- a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts +++ b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts @@ -76,7 +76,7 @@ export interface paths { }; get?: never; put?: never; - /** @description Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns under the same opt-in with current read and create grants on the bound ACTIVE Workspace generation and storage. Cancellation of running work requires the creator’s read grant and the opt-in, even after create authority is revoked; Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. The per-caller workspaceTurns capability on this surface advertises the same rule. Freeze selection with the original idempotency key; admission does not prove physical directory readiness. */ + /** @description Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns under the same opt-in with current read and create grants on the bound ACTIVE Workspace generation and storage. Cancellation of running work requires the creator’s read grant and the opt-in, even after create authority is revoked (a live cancel reuses the owner's resident Harness attachment; after a restart or takeover the owner re-attaches through Workspace execution authority, so while that authority refuses, the Turn may stay cancelling); Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. The per-caller workspaceTurns capability on this surface advertises the same rule. Freeze selection with the original idempotency key; admission does not prove physical directory readiness. */ post: operations["webShellCreateSession"]; delete?: never; options?: never; diff --git a/packages/web-shell/client/components/managed/java-managed-agent-provider.test.ts b/packages/web-shell/client/components/managed/java-managed-agent-provider.test.ts index 725ff3a543a..5cd90345229 100644 --- a/packages/web-shell/client/components/managed/java-managed-agent-provider.test.ts +++ b/packages/web-shell/client/components/managed/java-managed-agent-provider.test.ts @@ -643,31 +643,41 @@ describe('createJavaManagedAgentProvider', () => { ).toEqual({ canSend: false, canCancel: false }); }); - it('lets the allowed caller cancel a running Turn of a bound Session', async () => { - const provider = createJavaManagedAgentProvider({ - baseUrl: 'https://product.example', - fetch: vi.fn().mockResolvedValue( - jsonResponse({ - sessionId: 'bound-1', - status: 'ACTIVE', - createdAt: 1, - updatedAt: 2, - lastSequence: 5, - workspace: { workspaceId: 'ws-a', cwdRelative: '.' }, - activeTurn: { - turnId: 'turn-2', + it.each([true, false])( + 'offers cancel for a running bound Turn when workspaceTurns is %s', + async (workspaceTurns) => { + const provider = createJavaManagedAgentProvider({ + baseUrl: 'https://product.example', + fetch: vi.fn().mockResolvedValue( + jsonResponse({ sessionId: 'bound-1', - status: 'RUNNING', - submittedAt: 2, - }, - capabilities: { tasks: true, workspaceTurns: true }, - }), - ), - }); - expect( - (await provider.getSession('bound-1', { clientId: 'c' })).capabilities, - ).toEqual({ canSend: false, canCancel: true, workspaceTurns: true }); - }); + status: 'ACTIVE', + createdAt: 1, + updatedAt: 2, + lastSequence: 5, + workspace: { workspaceId: 'ws-a', cwdRelative: '.' }, + activeTurn: { + turnId: 'turn-2', + sessionId: 'bound-1', + status: 'RUNNING', + submittedAt: 2, + }, + capabilities: { tasks: true, workspaceTurns }, + }), + ), + }); + // The creator may still cancel after the Workspace stops admitting + // new work, so cancel does not follow workspaceTurns; the server + // refuses anyone else. + expect( + (await provider.getSession('bound-1', { clientId: 'c' })).capabilities, + ).toEqual({ + canSend: false, + canCancel: true, + ...(workspaceTurns ? { workspaceTurns: true } : {}), + }); + }, + ); it('passes download cancellation through the host sink to the content fetch', async () => { const abort = new AbortController(); diff --git a/packages/web-shell/client/components/managed/java-managed-agent-provider.ts b/packages/web-shell/client/components/managed/java-managed-agent-provider.ts index a48583d53c4..3d2d72aa4c9 100644 --- a/packages/web-shell/client/components/managed/java-managed-agent-provider.ts +++ b/packages/web-shell/client/components/managed/java-managed-agent-provider.ts @@ -293,11 +293,10 @@ function toSessionSummary( ...(session.capabilities?.artifacts === true ? { artifacts: true } : {}), canSend: sessionActive && !active && (!session.workspace || workspaceTurns), - canCancel: - sessionActive && - active && - turnStatus !== 'cancelling' && - (!session.workspace || workspaceTurns), + // The creator may cancel a running bound Turn after the Workspace stops + // admitting new work, so cancel is not tied to workspaceTurns; the + // server refuses anyone else. + canCancel: sessionActive && active && turnStatus !== 'cancelling', ...(workspaceTurns ? { workspaceTurns: true } : {}), ...(session.capabilities?.actions === true ? { actions: true } : {}), }, From 1701827a8d0e4f2c72b6fafc873068722f7247a4 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 3 Oct 2026 18:22:20 +0900 Subject: [PATCH 46/73] fix(managed-agent): keep a retired rename from reverting a newer title completeSessionMutation accepts a FAILED receipt so a same-key sibling that entered the Harness before the retirement can still complete. Nothing ordered that completion: while the receipt was FAILED, requireNoOpenOperation no longer saw it, so a rename under a fresh key could begin and complete, and the late sibling then overwrote that newer title with a 200 and a second session.updated event. Refuse to complete a FAILED receipt when a mutation of the same kind completed after its requested event, answering 409 session_mutation_superseded. Mutation commands on a Session begin one at a time, so event sequence ids order this strictly where millisecond timestamps could tie. A same-key request sent after the newer rename revives the receipt to PENDING and still applies, so the newest request wins as before. Reported on #13112 (ManagedAgentStore.java:565 review thread). --- .../sdk-java/managed-agent-server/README.md | 6 ++- .../managedagent/store/ManagedAgentStore.java | 35 +++++++++++++ .../ManagedSessionLifecycleTest.java | 49 +++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/packages/sdk-java/managed-agent-server/README.md b/packages/sdk-java/managed-agent-server/README.md index 7b179e12947..8a5eaa72580 100644 --- a/packages/sdk-java/managed-agent-server/README.md +++ b/packages/sdk-java/managed-agent-server/README.md @@ -195,7 +195,11 @@ unarchive restores it to closed. Rename waits for the Harness to durably commit while retaining its receipt and request digest. The same key retries the same content with the replay flag set; changed content or a different Session conflicts. A successful concurrent request can still complete -the receipt, and a failing sibling cannot overwrite that completed outcome. +the receipt, and a failing sibling cannot overwrite that completed outcome. It +cannot complete a retired receipt once a later rename has completed either: that +sibling answers `409 session_mutation_superseded` and the newer title stays. A +same-key request sent after the later rename is the newest request and still +applies. Retries do not re-append the original `requested` event. If the command store is unavailable during cleanup, the original API failure is preserved and the same key can resume its receipt when storage returns. Only an in-flight diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java index 95fff47d247..cd5a442e841 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java @@ -566,6 +566,17 @@ public SessionRecord completeSessionMutation(String tenantId, throw new IllegalStateException( "Session mutation command has an unknown status"); } + // A retired receipt still completes for a sibling that entered the + // Harness before the retirement, but never over a later mutation: + // while it was FAILED another key could begin and complete, and + // completing this one now would revert that newer outcome. + if ("FAILED".equals(command.status()) && supersededByLaterMutation( + tenantId, sessionId, operation, idempotencyKey, kind)) { + throw new ApiException(HttpStatus.CONFLICT, + "session_mutation_superseded", + "A later change to the Session completed after this" + + " request was retired."); + } validateMutationStatus(session, kind); long now = clock.millis(); Map data = Map.of("sessionId", sessionId); @@ -2205,6 +2216,30 @@ private static String mutationEvent(SessionMutationKind kind, + "." + phase; } + // Mutation commands on one Session begin one at a time, so a completion + // sequenced after this command's requested event belongs to a command + // that began after this one stopped being PENDING. Sequence ids order + // that strictly, where millisecond timestamps can tie. + private boolean supersededByLaterMutation(String tenantId, + String sessionId, String operation, String idempotencyKey, + SessionMutationKind kind) { + List requested = jdbc.queryForList("SELECT sequence_id FROM" + + " managed_agent_event WHERE tenant_id = ? AND" + + " session_id = ? AND source_key = ?", + Long.class, tenantId, sessionId, + mutationSource(operation, idempotencyKey, "requested")); + if (requested.isEmpty()) { + return false; + } + Integer later = jdbc.queryForObject("SELECT COUNT(*) FROM" + + " managed_agent_event WHERE tenant_id = ? AND" + + " session_id = ? AND sequence_id > ? AND" + + " event_type = ? AND source_key LIKE 'control:%'", + Integer.class, tenantId, sessionId, requested.getFirst(), + mutationEvent(kind, "completed")); + return later != null && later > 0; + } + private static String mutationSource(String operation, String idempotencyKey, String phase) { return "control:" + operation + ":" + idempotencyKey + ":" + phase; diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java index e9364d03e19..916a11c1cbf 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java @@ -498,6 +498,55 @@ void failedRenameReceiptKeepsItsDigestAndConcurrentCompletion() throws Exception Integer.class, tenant, sessionId)).isEqualTo(1); } + @Test + void retiredRenameCannotCompleteOverALaterCompletedRename() throws Exception { + String tenant = tenant(); + String sessionId = attachedSession(tenant); + String bootId = store.requireSession(tenant, sessionId).harnessBootId(); + RequestDigests digests = new RequestDigests(); + String first = digests.digest( + java.util.Map.of("sessionId", sessionId, "title", "A")); + String second = digests.digest( + java.util.Map.of("sessionId", sessionId, "title", "B")); + // K1 and its same-key retry both enter the Harness; one fails and + // retires K1, which frees the Session for a fresh key. + store.beginSessionMutation(tenant, "RENAME_SESSION", "k1", first, + sessionId, SessionMutationKind.RENAME); + store.beginSessionMutation(tenant, "RENAME_SESSION", "k1", first, + sessionId, SessionMutationKind.RENAME); + store.abandonSessionMutation(tenant, "RENAME_SESSION", "k1", sessionId); + store.beginSessionMutation(tenant, "RENAME_SESSION", "k2", second, + sessionId, SessionMutationKind.RENAME); + store.completeSessionMutation(tenant, "RENAME_SESSION", "k2", sessionId, + SessionMutationKind.RENAME, "B", bootId); + + // The sibling still inside the Harness finishes K1 last: it must + // not revert the newer committed title. + assertThatThrownBy(() -> store.completeSessionMutation(tenant, + "RENAME_SESSION", "k1", sessionId, SessionMutationKind.RENAME, + "A", bootId)) + .isInstanceOfSatisfying(ApiException.class, error -> + assertThat(error.getCode()) + .isEqualTo("session_mutation_superseded")); + assertThat(store.requireSession(tenant, sessionId).title()).isEqualTo("B"); + assertThat(jdbc.queryForObject("SELECT command_status FROM" + + " managed_agent_command WHERE tenant_id = ? AND" + + " operation = 'RENAME_SESSION' AND idempotency_key = 'k1'", + String.class, tenant)).isEqualTo("FAILED"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_event" + + " WHERE tenant_id = ? AND session_id = ?" + + " AND event_type = 'session.updated'", + Integer.class, tenant, sessionId)).isEqualTo(1); + + // A same-key request sent after K2 is the newest request, so it + // still re-drives K1 and wins. + lifecycle(patch("/v1/agents/sessions/{id}", sessionId) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"A\"}"), tenant, "k1") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.metadata.title").value("A")); + } + @Test void retryingFailedRenameAgainBlocksOtherLifecycleWork() throws Exception { String tenant = tenant(); From 41cb65191e63d6963e9dcbb5f8c33f45663f5bb9 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 3 Oct 2026 19:12:46 +0900 Subject: [PATCH 47/73] style(web-shell): format the bound Cancel page test with Prettier --- .../client/components/managed/ManagedSessionsPage.test.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx b/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx index b54c613716e..8624fae1875 100644 --- a/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx +++ b/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx @@ -879,9 +879,7 @@ describe('ManagedSessionsPage', () => { await render('bound'); // Without workspaceTurns the composer stays hidden, but Cancel does not. - expect(container.querySelector('textarea') !== null).toBe( - workspaceTurns, - ); + expect(container.querySelector('textarea') !== null).toBe(workspaceTurns); await click('Cancel turn'); expect(mocks.client.cancel).toHaveBeenCalledWith( From 262eb3a13c78615f2fbe7e44d499a0496e093a6a Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 3 Oct 2026 18:15:15 +0800 Subject: [PATCH 48/73] style(web-shell): apply prettier to ManagedSessionsPage.test.tsx The Lint & Static job failed on `node scripts/lint.js --prettier` with "Code style issues found in 1 file" for packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx. Reformat that one expectation so the branch matches the repo prettier style. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-conflict/jmus7hcqa2c --- .../client/components/managed/ManagedSessionsPage.test.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx b/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx index b54c613716e..8624fae1875 100644 --- a/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx +++ b/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx @@ -879,9 +879,7 @@ describe('ManagedSessionsPage', () => { await render('bound'); // Without workspaceTurns the composer stays hidden, but Cancel does not. - expect(container.querySelector('textarea') !== null).toBe( - workspaceTurns, - ); + expect(container.querySelector('textarea') !== null).toBe(workspaceTurns); await click('Cancel turn'); expect(mocks.client.cancel).toHaveBeenCalledWith( From 29e4d17d79f0d5231598f52d0f95dae9c91d5793 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 3 Oct 2026 21:18:01 +0800 Subject: [PATCH 49/73] fix(managed-agent): recover cold-cache cancels without new-work grants --- ...09-29-hosted-public-workspace-admission.md | 51 ++++-- ...hosted-public-workspace-admission.zh-CN.md | 13 +- .../src/serve/hosted-harness-session.test.ts | 49 ++++- .../cli/src/serve/hosted-harness-session.ts | 151 ++++++++++++++-- .../sdk-java/managed-agent-server/README.md | 18 +- .../harness/QwenHostedHarnessConnector.java | 19 +- .../service/HarnessCoordinator.java | 9 +- .../store/WorkspaceExecutionStore.java | 42 +++-- .../managed-agent-public-api.openapi.json | 4 +- .../managedagent/HostedPublicWorkspaceIT.java | 23 +++ .../ManagedWorkspaceAdmissionTest.java | 8 +- ...HostedHarnessColdCancelRegressionTest.java | 168 ++++++++++++++++++ .../QwenHostedHarnessConnectorTest.java | 9 +- .../managed/generated/managed-agent-api.ts | 2 +- 14 files changed, 488 insertions(+), 78 deletions(-) create mode 100644 packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessColdCancelRegressionTest.java diff --git a/docs/design/2026-09-29-hosted-public-workspace-admission.md b/docs/design/2026-09-29-hosted-public-workspace-admission.md index 9ce33f6933c..fd468836d6f 100644 --- a/docs/design/2026-09-29-hosted-public-workspace-admission.md +++ b/docs/design/2026-09-29-hosted-public-workspace-admission.md @@ -32,11 +32,19 @@ re-registration refuses submit and rename before any command is written. Cancelling only aborts work already running: while the deployment still enables Workspace files, the creator who can still read the Workspace may cancel even after the create grant is revoked, the Workspace starts draining or it is -re-registered. A live cancel reuses the running -Turn's resident attachment without re-running the execution authority; after a -restart or takeover the owner re-attaches through that authority, so while it -refuses, the Turn may stay cancelling. A cancel the -Harness did not take is re-sent while the Turn is still cancelling. After each +re-registered. A live cancel reuses the running Turn's resident attachment. +A cold connector cache re-attaches for the persisted cancellation, validating +its frozen Session binding and exact tenant/Session identity without requiring +mutable creation grants, registry state or mount readiness. New API requests +still require the creator's read grant; a cancellation already accepted keeps +retrying if that grant is later revoked. New work always rechecks execution +authority, including when physical recovery is disabled. Passive load of a +resident Harness Session returns the original client identity after validating +its tenant, Workspace, Session Store URL and frozen profile. It does not reopen +the writer or drive work; an inactive parked Runtime Turn is reported again if +a prior load reply was lost. This does not prove recovery after Broker/worker +process death or resolve an original prompt admission whose reply was lost. +A cancel the Harness did not take is re-sent while the Turn is still cancelling. After each successful lease renewal, the running owner observes cancellation requested through any API replica and sends it on the executor, keeping network waits off the lease scheduler. Failed deliveries retry at the lease renewal interval. @@ -60,7 +68,7 @@ reliable Workspace close. Cwd operations remain gated for bound Sessions. checks, Session creation, initial Turn and actor-scoped idempotency remain in the existing creation transaction. Replays preserve the original identities and binding; changed payloads conflict. -- Before attaching a bound Session, the connector rechecks the persisted binding +- Before attaching a bound Session for new work, the connector rechecks the persisted binding through `WorkspaceExecutionStore.authorize`. Broker acquisition and execution retain their own grant, generation, storage and ownership checks. No failed binding falls back to the global Workspace or an unbound no-tool Session. @@ -70,21 +78,22 @@ reliable Workspace close. Cwd operations remain gated for bound Sessions. - Cold load of unsettled input remains blocked. G0 does not enable in-flight continuation, adopt workers, remove affinity or change the G1 failover gates. -- A live cancellation reuses its admitted Harness attachment and is retried by the current lease owner. It never certifies a terminal failure from a fresh attach refusal. Recorded rename failures retain a `FAILED` command receipt and digest; same-content retries are replays, conflicting content remains rejected, and a concurrent success can complete the retained receipt. +- A cancellation reuses its admitted Harness attachment or passively re-attaches from a cold connector cache and is retried by the current lease owner. It never certifies a terminal failure from a fresh attach refusal. Recorded rename failures retain a `FAILED` command receipt and digest; same-content retries are replays, conflicting content remains rejected, and a concurrent success can complete the retained receipt. ## Changes and ownership -| Layer | Change | Scope | -| ----------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------- | -| Java configuration | Explicit file admission opt-in and dependency validation | Deployment | -| Creation service and SQL store | Admit initial input only under fixed, authorized Workspace configuration | Tenant, creator and persisted Workspace | -| Coordinator | Dispatch admitted bound Turns only when the opt-in is enabled | Persisted Session and leased Turn | -| Java connector and private SDK DTOs | Resolve the Session binding and pass the profile on create/load | Persisted Session and live Harness owner | -| Existing Broker/worker | Reuse production routing and fencing | Selected Runtime and persisted Workspace | -| Contract and README | Document the narrow creation capability and remaining gates | Public REST and WebShell adapter | +| Layer | Change | Scope | +| ----------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------- | +| Java configuration | Explicit file admission opt-in and dependency validation | Deployment | +| Creation service and SQL store | Admit initial input only under fixed, authorized Workspace configuration | Tenant, creator and persisted Workspace | +| Coordinator | Dispatch admitted bound Turns only when the opt-in is enabled | Persisted Session and leased Turn | +| Java connector and private SDK DTOs | Resolve the Session binding and pass the profile on create/load | Persisted Session and live Harness owner | +| Hosted private load route | Reuse resident connection after frozen identity checks; report parked Turn passively | Live Session owner | +| Existing Broker/worker | Reuse production routing and fencing | Selected Runtime and persisted Workspace | +| Contract and README | Document the narrow creation capability and remaining gates | Public REST and WebShell adapter | Production behavior changes under `packages/sdk-java/managed-agent-server`, in -the private Hosted DTOs in `packages/sdk-java/qwencode`, and in the WebShell +the private Hosted DTOs in `packages/sdk-java/qwencode`, in the CLI Hosted Session routes (`packages/cli`), and in the WebShell managed Sessions page and its providers (`packages/web-shell`); it covers the initial Workspace Read/Write/Edit Turn and the creator's later-Turn submit, cancel and rename admission. No core authority, tool @@ -125,7 +134,11 @@ while cwd remains gated), and unbound no-tool regression paths. Focused SDK serialization, connector, store/admission and coordinator tests -cover create/load identity, authorization rechecks and disabled gates. Run the +cover create/load identity, authorization rechecks and disabled gates. The real +Hosted stack must cancel after creation authority is revoked and the connector +cache is cleared; generation-only and storage-only drift must refuse new work +before any command is written. New cancellation requests after read revocation +must remain hidden, while previously accepted cancellations still retry. Run the Hosted integration on H2 locally and include it in the existing Hosted MySQL CI suite. Record separately whether local MySQL is available. Build, typecheck, bundle, focused tests and two clean diff audits precede completion. @@ -139,3 +152,7 @@ creator, above), lifecycle enablement, distributed provisioning and W0e/G1–G3 recovery remain separate. The existing `EmbeddedRuntimeBroker` is a production component and remains allowed; the E2E must not replace it or bypass admission with direct store calls. + +The late-rename supersession check protects the public SQL title and receipt. +It runs after the Harness title write, so it does not order overlapping Harness +writes. That inherited lifecycle issue remains tracked in #13269. diff --git a/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md b/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md index d36b8e5881b..70fa33a2f37 100644 --- a/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md +++ b/docs/design/2026-09-29-hosted-public-workspace-admission.zh-CN.md @@ -10,18 +10,18 @@ Hosted Read/Write/Edit 已能经过生产 Broker 和 worker 执行,但目前 G0 开放随创建会话准入的一次初始文件工具轮次,使用现有公开 REST 路由及共享 service 的 WebShell 创建适配器。发现接口仍仅广播 Workspace 绑定能力,不宣称完整的 Workspace 执行支持。G0 本身无需修改 UI;后续改动唯一的 UI 变化是为创建者开放输入框与取消控件。 -后续改动在同一开关下为会话创建者开放后续 Turn,并允许创建者取消正在运行的 Turn,由 Hosted Harness 中止该 Turn 并按原始 Runtime 身份结算。执行时每个 Turn 都按创建者的 Workspace 授权校验,因此其他 actor 以及未开启该开关的部署仍得到现有拒绝:actor 可读取该 Workspace 时为 `workspace_unavailable`,不能读取时为 `session_not_found`。创建者也可以重命名该会话。准入新工作要求创建者在 `ACTIVE` 的 Workspace 上持有创建授权,且 Workspace 的 generation 与存储与会话绑定时一致,因此重新注册后提交和改名会在写入任何命令之前被拒绝。取消只中止已在运行的工作:部署仍开启 Workspace 文件能力时,仍能读取该 Workspace 的创建者,即使创建授权被撤销、Workspace 进入 drain 或被重新注册,也可以取消。正常取消复用运行中 Turn 驻留在内存中的挂接,不再执行执行授权;重启或接管后 owner 需经执行授权重新挂接,授权拒绝期间 Turn 可能一直处于取消中。Harness 未接收的取消会在 Turn 仍处于取消中时重发。每次成功续租后,执行 owner 检查任意 API 副本受理的取消请求,并在 executor 中发送,避免网络等待阻塞续租调度器。投递失败按续租间隔重试。工作区关闭通过独立的关闭能力及生命周期准入控制;归档、删除与取消归档遵循可靠工作区关闭后的独立保留能力;cwd 操作仍保持门禁。 +后续改动在同一开关下为会话创建者开放后续 Turn,并允许创建者取消正在运行的 Turn,由 Hosted Harness 中止该 Turn 并按原始 Runtime 身份结算。执行时每个 Turn 都按创建者的 Workspace 授权校验,因此其他 actor 以及未开启该开关的部署仍得到现有拒绝:actor 可读取该 Workspace 时为 `workspace_unavailable`,不能读取时为 `session_not_found`。创建者也可以重命名该会话。准入新工作要求创建者在 `ACTIVE` 的 Workspace 上持有创建授权,且 Workspace 的 generation 与存储与会话绑定时一致,因此重新注册后提交和改名会在写入任何命令之前被拒绝。取消只中止已在运行的工作:部署仍开启 Workspace 文件能力时,仍能读取该 Workspace 的创建者,即使创建授权被撤销、Workspace 进入 drain 或被重新注册,也可以取消。正常取消复用运行中 Turn 驻留在内存中的挂接。连接器缓存丢失后,按已持久化的取消请求重新挂接,验证冻结的 Session 绑定和精确的 tenant/Session 身份,不再要求可变的创建授权、registry 状态或挂载就绪。新的 API 请求仍要求创建者的读取授权;已受理的取消即使随后失去读取授权也继续重试。新工作始终重新验证执行授权,包括关闭物理恢复功能时。对 Harness 中驻留会话的 passive load,在验证租户、Workspace、Session Store URL 和冻结 profile 后返回原始 client 身份,不重新打开 writer 或驱动工作;若之前的 load 回复丢失,会再次报告未运行但仍停驻的 Runtime Turn。这不证明 Broker/worker 进程死亡后的恢复,也不解决原始 prompt 准入回复丢失的问题。Harness 未接收的取消会在 Turn 仍处于取消中时重发。每次成功续租后,执行 owner 检查任意 API 副本受理的取消请求,并在 executor 中发送,避免网络等待阻塞续租调度器。投递失败按续租间隔重试。工作区关闭通过独立的关闭能力及生命周期准入控制;归档、删除与取消归档遵循可靠工作区关闭后的独立保留能力;cwd 操作仍保持门禁。 ## 决策 - 部署显式启用 `harness.workspace-files-enabled`(环境变量 `QWEN_MANAGED_AGENT_WORKSPACE_FILES_ENABLED`),默认关闭。它要求 Hosted Harness、HTTP Session Store,以及同机、会话隔离的 local-process Broker。原有无绑定的无工具会话行为不变。关闭开关后拒绝携带输入的创建请求(包括重试);空输入的绑定会话创建和读取保持可用。 - 仅接受 `qwen-code` 及现已支持并冻结的 `managed-runtime-tools/1` / `preapproved-workspace-tools/1` Workspace 配置组合。服务端选择 `hosted-workspace-files/1`。公开请求不能选择 profile,也不能通过 metadata 提升权限。 - Workspace 解析、ACTIVE 状态、创建者读取/创建权限、固定 profile 校验、会话创建、初始 Turn 和 actor 范围的幂等继续位于现有创建事务内。重试保留原身份与绑定;载荷改变产生冲突。 -- 连接有绑定的会话之前,connector 通过 `WorkspaceExecutionStore.authorize` 重新检查持久绑定。Broker 获取与执行仍保留各自的权限、代数、存储和所有权检查。任何绑定失败都不能回退到全局 Workspace 或无绑定的无工具会话。 +- 为新工作连接有绑定的会话之前,connector 通过 `WorkspaceExecutionStore.authorize` 重新检查持久绑定。Broker 获取与执行仍保留各自的权限、代数、存储和所有权检查。任何绑定失败都不能回退到全局 Workspace 或无绑定的无工具会话。 - 私有 create 与 load 都传递所选 profile 及持久 Workspace ID,包括创建冲突和创建结果不明后回退到 load 的路径。Harness 现有的不可变 definition 检查固定 profile。 - 冷加载未结算输入仍被阻塞。G0 不启用在飞续接、接管 worker、取消 owner 粘性,也不改动 G1 failover 门禁。 -- 取消运行中的 Turn 复用已准入的 Harness 连接,并由当前租约 owner 重试,不根据重新连接的拒绝伪造终态失败。已记录的重命名失败保留 `FAILED` 命令回执与摘要;相同内容重试仍是重放,不同内容继续冲突,并发成功请求仍可完成该回执。 +- 取消运行中的 Turn 复用已准入的 Harness 连接,或在连接器冷缓存下被动重新挂接,并由当前租约 owner 重试,不根据重新连接的拒绝伪造终态失败。已记录的重命名失败保留 `FAILED` 命令回执与摘要;相同内容重试仍是重放,不同内容继续冲突,并发成功请求仍可完成该回执。 ## 改动与归属 @@ -31,10 +31,11 @@ G0 开放随创建会话准入的一次初始文件工具轮次,使用现有 | 创建 service 与 SQL store | 仅在固定且获授权的 Workspace 配置下接受初始输入 | 租户、创建者及持久 Workspace | | Coordinator | 仅在开关启用时派发已准入的绑定轮次 | 持久会话及持有租约的 Turn | | Java connector 与私有 SDK DTO | 解析会话绑定,create/load 传递 profile | 持久会话及存活 Harness owner | +| Hosted 私有 load 路由 | 校验冻结身份后复用驻留连接,被动报告停驻 Turn | 存活 Session owner | | 现有 Broker/worker | 复用生产路由与 fencing | 所选 Runtime 及持久 Workspace | | 契约与 README | 记录有限的创建能力及剩余门禁 | 公开 REST 与 WebShell 适配器 | -生产行为在 `packages/sdk-java/managed-agent-server`、`packages/sdk-java/qwencode` 的私有 Hosted DTO,以及 WebShell 托管会话页及其 provider(`packages/web-shell`)中变化,覆盖初始 Workspace Read/Write/Edit Turn 与创建者后续 Turn 的提交、取消和重命名准入。不需要为 core authority、工具执行循环、数据库 schema 或公开请求字段新增抽象。 +生产行为在 `packages/sdk-java/managed-agent-server`、`packages/sdk-java/qwencode` 的私有 Hosted DTO、CLI Hosted 会话路由(`packages/cli`),以及 WebShell 托管会话页及其 provider(`packages/web-shell`)中变化,覆盖初始 Workspace Read/Write/Edit Turn 与创建者后续 Turn 的提交、取消和重命名准入。不需要为 core authority、工具执行循环、数据库 schema 或公开请求字段新增抽象。 合入的改动还涉及该范围之外的两处,均不增加运行时行为: @@ -47,8 +48,10 @@ G0 开放随创建会话准入的一次初始文件工具轮次,使用现有 初始轮次必须在所选 Workspace 的相对 cwd 下写、编辑并读取文件,产生持久工具历史和恰好一个公开终态事件,且不改动 Harness 的诱饵目录。重复创建幂等键,验证相同 Session/Turn 且无额外模型/工具副作用。验证改变载荷冲突、未授权租户/actor 无法创建或读取、不支持的 profile 与不可用 Workspace 被拒绝,以及关闭开关后保持原门禁。覆盖共享 WebShell 创建适配器、实际发生变化的后续操作门禁(创建者的后续 Turn 提交、取消与重命名被放行;关闭与保留操作遵循独立能力,cwd 操作仍受限)和无绑定无工具回归路径。 -SDK 序列化、connector、store/准入及 coordinator 的定向测试覆盖 create/load 身份、权限复核与关闭的门禁。本地通过 H2 跑 Hosted 集成,并加入现有 Hosted MySQL CI 套件;单独记录本地 MySQL 是否可用。完成前执行 build、typecheck、bundle、定向测试和两轮无发现的完整 diff 自查。 +SDK 序列化、connector、store/准入及 coordinator 的定向测试覆盖 create/load 身份、权限复核与关闭的门禁。真实 Hosted 链路必须在创建授权被撤销且连接器缓存清空后完成取消;只变 generation 或只变 storage 时,必须在写入任何命令之前拒绝新工作。撤销读取授权后的新取消请求仍必须隐藏会话,而已经受理的取消继续重试。本地通过 H2 跑 Hosted 集成,并加入现有 Hosted MySQL CI 套件;单独记录本地 MySQL 是否可用。完成前执行 build、typecheck、bundle、定向测试和两轮无发现的完整 diff 自查。 ## 边界与待定事项 本次实现把 G0 放在 #12952 下;以后调整到 D 或 W 跟踪不改变契约,也不决定 G3 的范围。Shell、审批、D8 AgentDefinition、公开 profile 选择、后续 Turn(此后已对创建者开放,见上文)、生命周期开放、分布式供给及 W0e/G1–G3 恢复均另行推进。现有 `EmbeddedRuntimeBroker` 是生产组件,可以继续使用;E2E 不得替换它或通过直接调用 store 绕过准入。 + +晚到改名的 supersession 检查保护公开 SQL 标题与回执。该检查发生在 Harness 写入标题之后,因此不保证重叠 Harness 写入的顺序。这个继承的生命周期问题继续由 #13269 跟踪。 diff --git a/packages/cli/src/serve/hosted-harness-session.test.ts b/packages/cli/src/serve/hosted-harness-session.test.ts index 87f59e97f1c..111562cedf9 100644 --- a/packages/cli/src/serve/hosted-harness-session.test.ts +++ b/packages/cli/src/serve/hosted-harness-session.test.ts @@ -5712,10 +5712,42 @@ describe('Hosted Harness tool approvals', () => { }, ); - it('cancels a waiting approval through the cancel route and releases the Workspace', async () => { + it('passively reattaches a live Turn and cancels it without opening another writer', async () => { const { server, clientId, answer, status } = await waitingSession(); + for (const changed of [ + { tenantId: 'other' }, + { workspaceId: 'other' }, + { baseUrl: 'http://other-store.test' }, + ]) { + await headers(supertest(server).post(`/session/${SESSION_ID}/load`)) + .send({ + managedSessionStore: { ...store(), ...changed }, + passiveManagedRuntimeRecovery: true, + }) + .expect(404); + } + await headers(supertest(server).post(`/session/${SESSION_ID}/load`)) + .send({ managedSessionStore: store(), toolProfile: files }) + .expect(409); + await headers(supertest(server).post(`/session/${SESSION_ID}/load`)) + .send({ + managedSessionStore: store(), + toolProfile: 'hosted-workspace-shell/1', + passiveManagedRuntimeRecovery: true, + }) + .expect(409); + const loaded = await headers( + supertest(server).post(`/session/${SESSION_ID}/load`), + ).send({ + managedSessionStore: store(), + passiveManagedRuntimeRecovery: true, + }); + expect(loaded.status).toBe(200); + expect(loaded.body).toMatchObject({ clientId, approvalMode: 'default' }); + expect(loaded.body._meta).toBeUndefined(); + expect(await status()).toMatchObject({ hasActivePrompt: true }); await headers(supertest(server).post(`/session/${SESSION_ID}/cancel`)) - .set('X-Qwen-Client-Id', clientId) + .set('X-Qwen-Client-Id', loaded.body.clientId as string) .expect(204); await waitFor(async () => expect(await status()).toMatchObject({ @@ -6778,7 +6810,18 @@ describe('Hosted Harness Runtime turn takeover', () => { const { server, loaded } = await loadReplacement(true); expect(loaded.status).toBe(200); expect(acquireSpy).not.toHaveBeenCalled(); - const recovery = loaded.body._meta?.[ + // The owner may lose its first load reply after Harness registered it. + const reloaded = await replacementHeaders( + supertest(server).post(`/session/${SESSION_ID}/load`), + ).send({ + managedSessionStore: storeFor(BOOT_ID_2), + toolProfile: FILE_PROFILE, + passiveManagedRuntimeRecovery: true, + }); + expect(reloaded.status).toBe(200); + expect(reloaded.body.clientId).toBe(loaded.body.clientId); + expect(acquireSpy).not.toHaveBeenCalled(); + const recovery = reloaded.body._meta?.[ 'qwen.daemon.managedRuntimeRecovery' ] as { phase: string; diff --git a/packages/cli/src/serve/hosted-harness-session.ts b/packages/cli/src/serve/hosted-harness-session.ts index c398ab8c8c7..d0237723117 100644 --- a/packages/cli/src/serve/hosted-harness-session.ts +++ b/packages/cli/src/serve/hosted-harness-session.ts @@ -130,6 +130,8 @@ const RESTORE_CONTAINER_KINDS = new Set([ interface HostedSession { managed: ManagedSession; + storeBaseUrl: string; + definition: Record | null; clientId: string; cwd: string; streams: Set<() => void>; @@ -1255,6 +1257,29 @@ export function registerHostedHarnessSessionRoutes( const opening = new Set(); const epoch = contract.bootId.replaceAll('-', '_'); + const sendAttachment = ( + res: Response, + sessionId: string, + session: HostedSession, + recovery?: HostedRuntimeRecoveryReport, + ): void => { + res.status(200).json({ + sessionId, + clientId: session.clientId, + workspaceCwd: session.cwd, + lastEventId: session.managed.authority.committedSequence, + eventEpoch: epoch, + // A Harness older than approvals omits this, so a caller can tell. + ...(session.approval ? { approvalMode: session.approval.mode } : {}), + ...(session.blocked || session.hooks?.hasPendingOperations + ? { recoveryRequired: true } + : {}), + ...(recovery + ? { _meta: { 'qwen.daemon.managedRuntimeRecovery': recovery } } + : {}), + }); + }; + const open = async ( req: Request, res: Response, @@ -1343,10 +1368,116 @@ export function registerHostedHarnessSessionRoutes( error(res, 409, 'hosted_harness_generation_mismatch'); return; } - if (sessions.has(sessionId) || opening.has(sessionId)) { + const resident = sessions.get(sessionId); + if ( + opening.has(sessionId) || + (resident && (create || body?.['passiveManagedRuntimeRecovery'] !== true)) + ) { error(res, 409, 'hosted_session_already_attached'); return; } + if (resident) { + const key = resident.managed.authority.sessionHeader.sessionKey; + if ( + key.tenantId !== store.tenantId || + key.workspaceId !== store.workspaceId || + resident.storeBaseUrl !== store.baseUrl + ) { + error(res, 404, 'hosted_session_not_found'); + return; + } + if ( + toolProfile === undefined && + (resident.toolProfile === HOSTED_WORKSPACE_FILE_PROFILE || + resident.toolProfile === HOSTED_WORKSPACE_SHELL_PROFILE) + ) + toolProfile = resident.toolProfile; + const definition = resident.definition; + if ( + definition?.['toolProfile'] !== toolProfile || + JSON.stringify(definition?.['mcpServers']) !== + JSON.stringify(mcpServers) || + !isDeepStrictEqual( + definition?.['hookCatalog'], + hookCatalog ?? definition?.['hookCatalog'], + ) || + (toolProfile === HOSTED_WORKSPACE_SHELL_PROFILE && + captureBytes !== undefined && + definition?.['captureBytes'] !== captureBytes) + ) { + error(res, 409, 'hosted_tool_profile_conflict'); + return; + } + try { + // Reuse the live owner without reopening its writer or driving work. + // A lost passive-load reply must still report a parked Runtime Turn. + let recovery: HostedRuntimeRecoveryReport | undefined; + const parked = !resident.active && unsettledPromptId(resident); + if (resident.mcpClosing) { + error(res, 409, 'hosted_session_closing'); + return; + } + if ( + !resident.active && + !parked && + hasUnsettledInput( + resident, + resident.managed.authority.committedSequence, + ) + ) { + error(res, 409, 'hosted_turn_recovery_required'); + return; + } + if (parked) { + if (!resident.toolProfile || !brokerOptions || resident.hooks) { + error(res, 409, 'hosted_turn_recovery_required'); + return; + } + recovery = ( + await recoverHostedRuntimeTurn({ + session: resident.managed, + sessionId, + cwd: resident.cwd, + promptId: parked, + brokerOptions, + passive: true, + }) + )?.report; + if ( + !resident.active && + unsettledPromptId(resident) && + (!recovery || + parked !== unsettledPromptId(resident) || + recovery.checkpointId !== + resident.managed.authority.latestCheckpoint?.checkpointId || + recovery.activationId !== + resident.managed.activation.activationId) + ) { + error(res, 409, 'hosted_turn_recovery_required'); + return; + } + } + if (sessions.get(sessionId) !== resident) { + error(res, 404, 'hosted_session_not_found'); + return; + } + if (resident.mcpClosing) { + error(res, 409, 'hosted_session_closing'); + return; + } + sendAttachment( + res, + sessionId, + resident, + resident.active || !unsettledPromptId(resident) + ? undefined + : recovery, + ); + } catch { + error(res, 409, 'hosted_turn_recovery_required'); + } + return; + } const sessionKey = { tenantId: store.tenantId, workspaceId: store.workspaceId, @@ -1465,6 +1596,8 @@ export function registerHostedHarnessSessionRoutes( } const session: HostedSession = { managed, + storeBaseUrl: store.baseUrl, + definition, clientId: randomUUID(), cwd, streams: new Set(), @@ -1779,21 +1912,7 @@ export function registerHostedHarnessSessionRoutes( }); } sessions.set(sessionId, session); - res.status(200).json({ - sessionId, - clientId: session.clientId, - workspaceCwd: cwd, - lastEventId: managed.authority.committedSequence, - eventEpoch: epoch, - // A Harness older than approvals omits this, so a caller can tell. - ...(pinned ? { approvalMode: pinned.mode } : {}), - ...(session.blocked || session.hooks?.hasPendingOperations - ? { recoveryRequired: true } - : {}), - ...(recovery - ? { _meta: { 'qwen.daemon.managedRuntimeRecovery': recovery } } - : {}), - }); + sendAttachment(res, sessionId, session, recovery); if (settlePromptId) { const originalPromptId = settlePromptId; const abort = new AbortController(); diff --git a/packages/sdk-java/managed-agent-server/README.md b/packages/sdk-java/managed-agent-server/README.md index 8a5eaa72580..7efa05990ac 100644 --- a/packages/sdk-java/managed-agent-server/README.md +++ b/packages/sdk-java/managed-agent-server/README.md @@ -197,7 +197,9 @@ key retries the same content with the replay flag set; changed content or a different Session conflicts. A successful concurrent request can still complete the receipt, and a failing sibling cannot overwrite that completed outcome. It cannot complete a retired receipt once a later rename has completed either: that -sibling answers `409 session_mutation_superseded` and the newer title stays. A +sibling answers `409 session_mutation_superseded` and the newer public SQL title stays. +The Harness title was already written before this check; ordering overlapping +Harness writes remains a follow-up tracked in #13269. A same-key request sent after the later rename is the newest request and still applies. Retries do not re-append the original `requested` event. If the command store @@ -500,14 +502,20 @@ Public bound Turn admission is limited to the opt-in initial file Turn described in G0 above and to later Turns submitted by the Session's creator under the same opt-in while they can still read and create in the Workspace (the per-caller `workspaceTurns` capability flag reflects the caller's current grants, the -registry's `ACTIVE` state and the Workspace generation the Session was bound +registry's `ACTIVE` state and the Workspace generation and storage the Session was bound to); the creator may also rename the Session. Cancelling aborts work that is already running, so the creator may cancel a running Turn while they can still read the Workspace, even after their create grant is revoked, the Workspace starts draining or it is re-registered. A live cancel reuses the owner's -resident Harness attachment; after a restart or takeover the owner re-attaches -through Workspace execution authority, so while that authority refuses, the -Turn may stay cancelling. Later Turns run +resident Harness attachment. A cold connector cache passively re-attaches for +the persisted cancellation after checking the frozen Session binding and exact +identity, without depending on current creation grants, registry state or mount +readiness. A resident passive load returns the original connection after scope +and profile checks; a lost passive recovery reply can be retried without driving +work. New API cancellation requests still require read access, while already +accepted cancellations continue if it is subsequently revoked. New work always +rechecks execution authority. Broker/worker process death and an original prompt +admission with a lost reply retain their separate recovery limitations. Later Turns run under the creator's Workspace grants, so any other actor keeps the existing refusal: `workspace_unavailable` when the actor can read the Workspace, `session_not_found` when they cannot. Public close follows its separate close diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java index e32a0348d41..e40cb8b1142 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java @@ -171,7 +171,7 @@ public Admission continueManagedRuntime(String tenantId, public Admission cancelManagedRuntime(String tenantId, String sessionId, String promptId, String checkpointId, String activationId) { PromptReceipt receipt = client().cancelManagedRuntime( - new CancelManagedRuntime(attachment(tenantId, sessionId, false), + new CancelManagedRuntime(cancellationAttachment(tenantId, sessionId), promptId, checkpointId, activationId)); pendingRecovery.remove(new AttachmentKey(tenantId, sessionId)); return new Admission(receipt.getLastEventId(), @@ -226,7 +226,7 @@ public void resolveAction( @Override public void cancel(String tenantId, String sessionId) { - client().cancelTurn(attachment(tenantId, sessionId, false)); + client().cancelTurn(cancellationAttachment(tenantId, sessionId)); } @Override @@ -265,10 +265,17 @@ private HarnessSessionRef attachment(String tenantId, String sessionId, boolean return attachment; } - private void requireReadyForNewWork(String tenantId, String sessionId) { - if (!workspaceExecution.verifiedRecoveryEnabled()) { - return; + private HarnessSessionRef cancellationAttachment(String tenantId, String sessionId) { + AttachmentKey key = new AttachmentKey(tenantId, sessionId); + HarnessSessionRef attached = attachments.get(key); + if (attached == null) { + recoverManagedRuntime(tenantId, sessionId, true); + attached = attachments.get(key); } + return attached; + } + + private void requireReadyForNewWork(String tenantId, String sessionId) { SessionRecord session = sessions.requireSession(tenantId, sessionId); if (session.workspace() != null) { if (!isWorkspaceFilesAvailable()) { @@ -335,7 +342,7 @@ public Attachment recoverManagedRuntime(String tenantId, String sessionId, + " require the Managed Action store"); } if (cancellation) { - workspaceExecution.authorizePassiveAttachment(session); + workspaceExecution.authorizeCancellation(session); } else { workspaceExecution.authorize(session); } diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java index 5c28c3921fa..407a92e4d11 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java @@ -558,12 +558,9 @@ private void cancelAdmittedTurn(String tenantId, String sessionId, && !harness.isWorkspaceFilesAvailable()) { return; } - // An admitted Turn's boot is already bound, so the cancel needs no - // attach: attaching re-runs the Workspace authorization, and - // aborting running work must not depend on the grants that admit - // new work. harness.cancel reuses the running Turn's attachment - // while it is resident; after a restart or takeover it re-attaches - // through that authority and keeps retrying while it refuses. + // Reuse the admitted owner; a cold connector cache re-attaches + // only for the persisted cancellation, without requiring the + // authority that admits new work. if (session.harnessBootId() != null && store.bindHarness(tenantId, sessionId, turnId, owner, session.harnessBootId())) { harness.cancel(session.tenantId(), session.sessionId()); diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceExecutionStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceExecutionStore.java index 0efe7f43d78..0da538e5856 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceExecutionStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceExecutionStore.java @@ -51,6 +51,14 @@ public void authorize(SessionRecord session) { } public void authorizePassiveAttachment(SessionRecord session) { + authorizeAttachment(session, false); + } + + public void authorizeCancellation(SessionRecord session) { + authorizeAttachment(session, true); + } + + private void authorizeAttachment(SessionRecord session, boolean cancellation) { ContextBinding binding = session.workspace(); if (binding == null || !"ACTIVE".equals(session.status()) || session.deletedAt() != null || !"qwen-code".equals(session.agentId()) @@ -68,18 +76,25 @@ public void authorizePassiveAttachment(SessionRecord session) { + " s.cwd_relative AS session_cwd," + " s.context_config_ref AS session_context," + " s.context_revision AS session_revision," - + " s.workspace_config_ref, s.workspace_policy_ref," - + " r.tenant_id AS registry_tenant, r.workspace_id," + + " s.workspace_config_ref, s.workspace_policy_ref" + + (cancellation + ? ", t.tenant_id AS cancellation_tenant, t.session_id AS cancellation_session" + : ", r.tenant_id AS registry_tenant, r.workspace_id," + " r.workspace_generation, r.storage_id, r.state," + " c.tenant_id AS command_tenant, c.session_id AS command_session," + " a.tenant_id AS access_tenant, a.workspace_id AS access_workspace," - + " a.can_read, a.can_create FROM managed_agent_session s" - + " JOIN managed_workspace_registry r ON r.tenant_id = s.tenant_id" + + " a.can_read, a.can_create") + + " FROM managed_agent_session s" + + (cancellation + ? " JOIN managed_agent_turn t ON t.tenant_id = s.tenant_id" + + " AND t.session_id = s.session_id AND t.status = 'CANCELLING'" + + " AND (t.submission_attempted = TRUE OR t.harness_event_epoch IS NOT NULL)" + : " JOIN managed_workspace_registry r ON r.tenant_id = s.tenant_id" + " AND r.workspace_id = s.workspace_id" + " JOIN managed_workspace_create_command c ON c.tenant_id = s.tenant_id" + " AND c.session_id = s.session_id" + " JOIN managed_workspace_access a ON a.tenant_id = r.tenant_id" - + " AND a.workspace_id = r.workspace_id AND a.actor_id = c.actor_id" + + " AND a.workspace_id = r.workspace_id AND a.actor_id = c.actor_id") + " WHERE s.tenant_id = ? AND s.session_id = ?", (row, index) -> session.tenantId().equals(row.getString("tenant_id")) && session.sessionId().equals(row.getString("session_id")) @@ -92,7 +107,16 @@ public void authorizePassiveAttachment(SessionRecord session) { && binding.getCwdRelative().equals(row.getString("session_cwd")) && binding.getContextConfigRef().equals(row.getString("session_context")) && binding.getContextRevision() == row.getLong("session_revision") - && session.tenantId().equals(row.getString("registry_tenant")) + && WorkspaceExecutionProfile.CONFIG_REF.equals( + row.getString("workspace_config_ref")) + && WorkspaceExecutionProfile.POLICY_REF.equals( + row.getString("workspace_policy_ref")) + // Cancellation was authorized when it was persisted; + // retries must not depend on mutable creation grants. + && (cancellation + ? session.tenantId().equals(row.getString("cancellation_tenant")) + && session.sessionId().equals(row.getString("cancellation_session")) + : session.tenantId().equals(row.getString("registry_tenant")) && session.tenantId().equals(row.getString("command_tenant")) && session.sessionId().equals(row.getString("command_session")) && session.tenantId().equals(row.getString("access_tenant")) @@ -101,11 +125,7 @@ public void authorizePassiveAttachment(SessionRecord session) { && binding.getWorkspaceGeneration() == row.getLong("workspace_generation") && binding.getStorageId().equals(row.getString("storage_id")) && "ACTIVE".equals(row.getString("state")) - && row.getBoolean("can_read") && row.getBoolean("can_create") - && WorkspaceExecutionProfile.CONFIG_REF.equals( - row.getString("workspace_config_ref")) - && WorkspaceExecutionProfile.POLICY_REF.equals( - row.getString("workspace_policy_ref")), + && row.getBoolean("can_read") && row.getBoolean("can_create")), session.tenantId(), session.sessionId()); if (grants.size() != 1 || !grants.getFirst()) { throw unavailable(); diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json index 00ce033ebb5..a49f5a945d0 100644 --- a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json +++ b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json @@ -184,7 +184,7 @@ "tags": ["Public Sessions"], "operationId": "createSession", "x-qwen-implementation-status": "implemented", - "description": "Workspace selection is persisted with the original actor-scoped creation receipt. With the deployment's G0 file admission opt-in, qwen-code Sessions in an authorized, preregistered Workspace may include initial input under the fixed preapproved file-tool profile. Public callers cannot select the profile. Otherwise Workspace creation must have empty input. The Session creator may submit later Turns and rename the Session under the same opt-in, while the creator currently holds Workspace read and create grants on a registry row whose state is ACTIVE and still carries the Workspace generation and storage identity the Session was bound to, and the Session is an active, undeleted qwen-code Session on the frozen execution profile. Cancelling only aborts work that is already running, so the creator may cancel a running Turn under the same opt-in while they can still read the Workspace, even after create authority is revoked, the registry row leaves ACTIVE or the Workspace is re-registered (a live cancel reuses the owner's resident Harness attachment; after a restart or takeover the owner re-attaches through Workspace execution authority, so while that authority refuses, the Turn may stay cancelling); Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. This surface has no per-caller capability flag for later Turns, so callers must handle a refused later Turn: 409 workspace_unavailable, or 404 session_not_found when the caller no longer holds Workspace read. Check replay before resolving defaults; a durable binding is not proof of Runtime readiness.", + "description": "Workspace selection is persisted with the original actor-scoped creation receipt. With the deployment's G0 file admission opt-in, qwen-code Sessions in an authorized, preregistered Workspace may include initial input under the fixed preapproved file-tool profile. Public callers cannot select the profile. Otherwise Workspace creation must have empty input. The Session creator may submit later Turns and rename the Session under the same opt-in, while the creator currently holds Workspace read and create grants on a registry row whose state is ACTIVE and still carries the Workspace generation and storage identity the Session was bound to, and the Session is an active, undeleted qwen-code Session on the frozen execution profile. Cancelling only aborts work that is already running, so the creator may cancel a running Turn under the same opt-in while they can still read the Workspace, even after create authority is revoked, the registry row leaves ACTIVE or the Workspace is re-registered (a live cancel reuses the owner's resident Harness attachment; a cold connector cache passively re-attaches for a persisted cancellation after frozen identity checks, independently of current creation grants, registry state or mount readiness; new work still rechecks execution authority); Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. This surface has no per-caller capability flag for later Turns, so callers must handle a refused later Turn: 409 workspace_unavailable, or 404 session_not_found when the caller no longer holds Workspace read. Check replay before resolving defaults; a durable binding is not proof of Runtime readiness.", "parameters": [ { "$ref": "#/components/parameters/IdempotencyKey" @@ -1129,7 +1129,7 @@ "tags": ["WebShell"], "operationId": "webShellCreateSession", "x-qwen-implementation-status": "implemented", - "description": "Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns under the same opt-in with current read and create grants on the bound ACTIVE Workspace generation and storage. Cancellation of running work requires the creator’s read grant and the opt-in, even after create authority is revoked (a live cancel reuses the owner's resident Harness attachment; after a restart or takeover the owner re-attaches through Workspace execution authority, so while that authority refuses, the Turn may stay cancelling); Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. The per-caller workspaceTurns capability on this surface advertises the same rule. Freeze selection with the original idempotency key; admission does not prove physical directory readiness.", + "description": "Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns under the same opt-in with current read and create grants on the bound ACTIVE Workspace generation and storage. Cancellation of running work requires the creator’s read grant and the opt-in, even after create authority is revoked (a live cancel reuses the owner's resident Harness attachment; a cold connector cache passively re-attaches for a persisted cancellation after frozen identity checks, independently of current creation grants, registry state or mount readiness; new work still rechecks execution authority); Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. The per-caller workspaceTurns capability on this surface advertises current submit and rename admission; it does not gate cancellation. Freeze selection with the original idempotency key; admission does not prove physical directory readiness.", "requestBody": { "required": true, "content": { diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java index 57645fe25f6..0069d705c9b 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java @@ -4,6 +4,7 @@ import static org.awaitility.Awaitility.await; import com.alibaba.qwen.code.managedagent.api.AuthenticatedTenantActor; +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector; import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -47,6 +48,7 @@ import org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext; import org.springframework.core.Ordered; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; class HostedPublicWorkspaceIT { private static final String TOKEN = "g0-local-fixture"; @@ -345,6 +347,8 @@ private void runFiles() throws Exception { tenant, workspace, "actor".getBytes(StandardCharsets.UTF_8)); jdbc.update("UPDATE managed_workspace_registry SET state = 'DRAINING'" + " WHERE tenant_id = ? AND workspace_id = ?", tenant, workspace); + // Harness still runs the Turn, but this Java owner has lost its ref. + ((Map) ReflectionTestUtils.getField(spring.getBean(HarnessConnector.class), "attachments")).clear(); Map revokedCancel = Map.of("type", "agent.session.cancel", "turn_id", revokedTurn); request("POST", "/v1/agents/sessions/" + session + "/events", revokedCancel, "nocreate-cancel-" + workspace, "actor", 202); @@ -387,6 +391,25 @@ private void runFiles() throws Exception { jdbc.update("UPDATE managed_workspace_access SET can_read = TRUE" + " WHERE tenant_id = ? AND workspace_id = ? AND actor_id = ?", tenant, workspace, "actor".getBytes(StandardCharsets.UTF_8)); + + String registeredStorage = jdbc.queryForObject("SELECT storage_id FROM managed_workspace_registry" + + " WHERE tenant_id = ? AND workspace_id = ?", String.class, tenant, workspace); + jdbc.update("UPDATE managed_workspace_registry SET " + + (index == 0 ? "workspace_generation = workspace_generation + 1" + : "storage_id = 'replacement-storage'") + + " WHERE tenant_id = ? AND workspace_id = ?", tenant, workspace); + assertThat(request("POST", "/api/agent/web-shell/v1/sessions/get", Map.of("sessionId", session), + null, "actor", 200).path("capabilities").path("workspaceTurns").asBoolean()).isFalse(); + assertUnavailable(request("POST", "/v1/agents/sessions/" + session + "/events", later, + "rebound-later-" + workspace, "actor", 409)); + assertUnavailable(request("PATCH", "/v1/agents/sessions/" + session, rename, + "rebound-rename-" + workspace, "actor", 409)); + request("POST", "/v1/agents/sessions/" + session + "/events", revokedCancel, + "rebound-cancel-" + workspace, "actor", 202); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_command" + + " WHERE tenant_id = ? AND command_status = 'PENDING'", Integer.class, tenant)).isZero(); + jdbc.update("UPDATE managed_workspace_registry SET workspace_generation = 1, storage_id = ?" + + " WHERE tenant_id = ? AND workspace_id = ?", registeredStorage, tenant, workspace); } assertThat(modelRequests).hasSize(approvals ? 16 : 20); assertThat(modelFailure.get()).isNull(); diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java index 93f8c54748b..ed0454ae88a 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java @@ -1151,13 +1151,15 @@ void creatorCancelsWithoutTheGrantsThatAdmitNewWork() { "workspace_unavailable"); } - @Test - void reRegistrationRefusesLaterWorkBeforeAnyCommandIsWritten() { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void reRegistrationRefusesLaterWorkBeforeAnyCommandIsWritten(boolean storageOnly) { String tenant = "tenant-" + UUID.randomUUID(); String sessionId = boundSession(tenant); ManagedAgentService enabled = boundService(true); jdbc.update("UPDATE managed_workspace_registry SET" - + " workspace_generation = workspace_generation + 1" + + (storageOnly ? " storage_id = 'replacement-storage'" + : " workspace_generation = workspace_generation + 1") + " WHERE tenant_id = ?", tenant); assertThat(enabled.getWebShellSession(tenant, "actor-a", sessionId) diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessColdCancelRegressionTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessColdCancelRegressionTest.java new file mode 100644 index 00000000000..261e76d3747 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessColdCancelRegressionTest.java @@ -0,0 +1,168 @@ +package com.alibaba.qwen.code.managedagent.harness; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.alibaba.qwen.code.daemon.HarnessSessionRef; +import com.alibaba.qwen.code.daemon.HostedHarnessCapabilities; +import com.alibaba.qwen.code.daemon.HostedHarnessClient; +import com.alibaba.qwen.code.managedagent.api.ApiException; +import com.alibaba.qwen.code.managedagent.api.WorkspaceSelection; +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.service.HarnessCoordinator; +import com.alibaba.qwen.code.managedagent.service.HarnessEventProjector; +import com.alibaba.qwen.code.managedagent.service.ManagedAgentService; +import com.alibaba.qwen.code.managedagent.service.RequestDigests; +import com.alibaba.qwen.code.managedagent.store.ManagedActionStore; +import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore; +import com.alibaba.qwen.code.managedagent.store.ManagedWorkspaceRegistry; +import com.alibaba.qwen.code.managedagent.store.WorkspaceExecutionStore; +import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; +import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:cold-cancel;MODE=MySQL;" + + "DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "qwen.managed-agent.harness.enabled=false" +}) +class QwenHostedHarnessColdCancelRegressionTest { + @Autowired + private JdbcTemplate jdbc; + + @Autowired + private ObjectMapper mapper; + + @Autowired + private ManagedWorkspaceRegistry registry; + + @Autowired + private PlatformTransactionManager transactionManager; + + @Test + @Transactional + void coldCancellationRequiresPersistedIntentAndSurvivesMutableAuthorityChanges() { + String tenant = "tenant-" + UUID.randomUUID(); + String boot = "11111111-1111-4111-8111-111111111111"; + jdbc.update("INSERT INTO managed_workspace_registry (tenant_id, workspace_id," + + " workspace_generation, storage_id, display_name, config_ref, policy_ref, state)" + + " VALUES (?, 'ws-a', 1, 'storage-a', 'Workspace', ?, ?, 'ACTIVE')", + tenant, WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF); + jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)" + + " VALUES (?, 'ws-a', ?, TRUE, TRUE)", + tenant, "actor-a".getBytes(StandardCharsets.UTF_8)); + ManagedAgentProperties properties = new ManagedAgentProperties(); + properties.getHarness().setWorkspaceFilesEnabled(true); + properties.getHarness().setToken("test-token"); + properties.getHarness().setCapabilityDigest("sha256:" + "a".repeat(64)); + ManagedAgentStore store = new ManagedAgentStore(jdbc, mapper, Clock.systemUTC(), + ignored -> { }, registry, properties); + var admission = store.insertWorkspaceSessionCommand(tenant, "actor-a", "create", "digest", + "qwen-code", null, null, List.of(), null, new WorkspaceSelection("ws-a", ".")); + String session = admission.sessionId(); + String turn = store.insertTurnCommand(tenant, "SUBMIT", "submit", "digest", session, + List.of(Map.of("type", "text", "text", "go")), "payload").turnId(); + assertThat(turn).isNotNull(); + + HostedHarnessClient client = mock(HostedHarnessClient.class); + HostedHarnessCapabilities capabilities = mock(HostedHarnessCapabilities.class); + HarnessSessionRef attached = mock(HarnessSessionRef.class); + when(client.capabilities()).thenReturn(capabilities); + when(capabilities.getBootId()).thenReturn(boot); + when(client.loadSession(any())).thenReturn(attached); + when(attached.getHarnessBootId()).thenReturn(boot); + when(attached.getApprovalMode()).thenReturn("yolo"); + ManagedActionStore actions = mock(ManagedActionStore.class); + when(actions.approvalMode(tenant, session)).thenReturn("yolo"); + WorkspaceExecutionStore execution = new WorkspaceExecutionStore(jdbc, transactionManager); + QwenHostedHarnessConnector connector = new QwenHostedHarnessConnector(properties, store, execution, actions); + ReflectionTestUtils.setField(connector, "client", client); + connector.createOrLoad(tenant, session, true); + + ExecutorService executor = mock(ExecutorService.class); + doAnswer(invocation -> { + invocation.getArgument(0).run(); + return null; + }).when(executor).execute(any(Runnable.class)); + HarnessCoordinator coordinator = new HarnessCoordinator(store, connector, + new HarnessEventProjector(), null, executor, Clock.systemUTC(), properties) { + @Override + public void dispatch(String tenantId, String sessionId, String turnId) { + } + }; + try { + String owner = (String) ReflectionTestUtils.getField(coordinator, "owner"); + assertThat(store.claimTurn(tenant, session, turn, owner, Duration.ofMinutes(1))).isPresent(); + assertThat(store.bindHarness(tenant, session, turn, owner, boot)).isTrue(); + store.markSubmissionAttempted(tenant, session, turn, owner); + store.recordAdmission(tenant, session, turn, owner, "epoch", 1); + assertThatThrownBy(() -> connector.recoverManagedRuntime(tenant, session, true)) + .isInstanceOfSatisfying(RuntimeBrokerException.class, + error -> assertThat(error.getCode()).isEqualTo("workspace_unavailable")); + jdbc.update("UPDATE managed_workspace_access SET can_create = FALSE WHERE tenant_id = ?", tenant); + assertThat(store.insertCancelCommand(tenant, "CANCEL", "cancel", "digest", session, turn) + .commandEffect()).isTrue(); + + coordinator.cancel(tenant, session, turn); + verify(client).cancelTurn(attached); + clearInvocations(client); + ((Map) ReflectionTestUtils.getField(connector, "attachments")).clear(); + + coordinator.cancel(tenant, session, turn); + + assertThat(store.findTurn(tenant, session, turn).orElseThrow().status()).isEqualTo("CANCELLING"); + verify(client).cancelTurn(attached); + assertThat(execution.verifiedRecoveryEnabled()).isFalse(); + assertThatThrownBy(() -> connector.submit(tenant, session, "later", List.of(), "digest")) + .isInstanceOf(RuntimeBrokerException.class); + verify(client, never()).submitTurn(any()); + + for (String change : List.of( + "UPDATE managed_workspace_access SET can_read = FALSE WHERE tenant_id = ?", + "UPDATE managed_workspace_registry SET state = 'DRAINING' WHERE tenant_id = ?", + "UPDATE managed_workspace_registry SET workspace_generation = workspace_generation + 1 WHERE tenant_id = ?", + "UPDATE managed_workspace_registry SET storage_id = 'replacement-storage' WHERE tenant_id = ?")) { + jdbc.update(change, tenant); + clearInvocations(client); + ((Map) ReflectionTestUtils.getField(connector, "attachments")).clear(); + coordinator.cancel(tenant, session, turn); + verify(client).cancelTurn(attached); + } + + ManagedAgentService service = new ManagedAgentService(store, new RequestDigests(), + coordinator, connector, registry); + clearInvocations(client); + assertThatThrownBy(() -> service.cancelTurn(tenant, "actor-a", "cancel-fresh", session, turn)) + .isInstanceOfSatisfying(ApiException.class, + error -> assertThat(error.getCode()).isEqualTo("session_not_found")); + assertThat(store.findCommand(tenant, "CANCEL", "cancel-fresh")).isEmpty(); + } finally { + coordinator.close(); + connector.close(); + } + } +} diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java index 21bb8153136..00080790a23 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java @@ -33,6 +33,8 @@ import java.util.Map; import org.mockito.ArgumentCaptor; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.dao.DataAccessResourceFailureException; import org.springframework.test.util.ReflectionTestUtils; @@ -237,15 +239,16 @@ void loadsAnExistingAuthorityAfterCreateConflicts() { verify(client).createSession(any(CreateHarnessSession.class)); } - @Test - void rechecksWorkspaceAuthorityOnCachedAttachmentAndKeepsPassiveRecoveryAuthorized() { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void rechecksWorkspaceAuthorityOnCachedAttachmentAndKeepsPassiveRecoveryAuthorized(boolean verifiedRecovery) { HostedHarnessClient client = mock(HostedHarnessClient.class); HostedHarnessCapabilities capabilities = mock(HostedHarnessCapabilities.class); HarnessSessionRef attached = mock(HarnessSessionRef.class); SessionRecord session = mock(SessionRecord.class); AgentStateStore sessions = mock(AgentStateStore.class); WorkspaceExecutionStore execution = mock(WorkspaceExecutionStore.class); - when(execution.verifiedRecoveryEnabled()).thenReturn(true); + when(execution.verifiedRecoveryEnabled()).thenReturn(verifiedRecovery); when(client.capabilities()).thenReturn(capabilities); when(capabilities.getBootId()).thenReturn(BOOT_ID); when(sessions.requireSession("tenant-a", SESSION_ID)).thenReturn(session); diff --git a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts index daea259be01..5a4a210dc32 100644 --- a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts +++ b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts @@ -76,7 +76,7 @@ export interface paths { }; get?: never; put?: never; - /** @description Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns under the same opt-in with current read and create grants on the bound ACTIVE Workspace generation and storage. Cancellation of running work requires the creator’s read grant and the opt-in, even after create authority is revoked (a live cancel reuses the owner's resident Harness attachment; after a restart or takeover the owner re-attaches through Workspace execution authority, so while that authority refuses, the Turn may stay cancelling); Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. The per-caller workspaceTurns capability on this surface advertises the same rule. Freeze selection with the original idempotency key; admission does not prove physical directory readiness. */ + /** @description Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns under the same opt-in with current read and create grants on the bound ACTIVE Workspace generation and storage. Cancellation of running work requires the creator’s read grant and the opt-in, even after create authority is revoked (a live cancel reuses the owner's resident Harness attachment; a cold connector cache passively re-attaches for a persisted cancellation after frozen identity checks, independently of current creation grants, registry state or mount readiness; new work still rechecks execution authority); Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. The per-caller workspaceTurns capability on this surface advertises current submit and rename admission; it does not gate cancellation. Freeze selection with the original idempotency key; admission does not prove physical directory readiness. */ post: operations["webShellCreateSession"]; delete?: never; options?: never; From 60030e4b0f9ce009d32b39a6659586dd225f8d63 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 3 Oct 2026 21:23:00 +0800 Subject: [PATCH 50/73] docs(managed-agent): include storage in both capability descriptions --- packages/sdk-java/managed-agent-server/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk-java/managed-agent-server/README.md b/packages/sdk-java/managed-agent-server/README.md index 7efa05990ac..85c4ba73696 100644 --- a/packages/sdk-java/managed-agent-server/README.md +++ b/packages/sdk-java/managed-agent-server/README.md @@ -365,7 +365,7 @@ not a filesystem sandbox. Later Turns may be submitted by the Session's creator under the same opt-in while they can still read and create in the Workspace (the per-caller `workspaceTurns` capability flag reflects the caller's current -grants, the Workspace registry's `ACTIVE` state and the Workspace generation the +grants, the Workspace registry's `ACTIVE` state and the Workspace generation and storage the Session was bound to), and the creator may rename the Session. The creator may also cancel a running Turn while they can still read the Workspace, under the cancel rule below. Workspace close follows From 8d98903588d6df9fdedac05497aaa79651bf0986 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 4 Oct 2026 00:07:13 +0800 Subject: [PATCH 51/73] refactor(managed-agent): delete the review-minted dead surface and its duplicate Closes four open review findings by deletion only; the diff shrinks by 92 lines and no production behavior changes. - R2-3: both convenience constructors on WebShellSessionCapabilities had zero call sites. The only construction is the canonical 8-arg call in ManagedAgentService.webShellSession; the contract test touches the record through .class for schema naming. The 2-arg overload also took (tasks, actions) while the record's components run (tasks, artifacts, actions), so it silently mapped its second argument to `actions`. - R2-4: isSessionCreator was a one-line wrapper over createdSession with the two String parameters reversed, leaving two public registry methods that differ only in argument order on a creator-identity authorization check. Deleted the wrapper; its single caller in ManagedAgentStore.requireWorkspaceCreator now calls createdSession with the declared (tenantId, actorId, sessionId) order. Equivalent by construction: the wrapper body was that same call. - R2-5 and R2-6: aRenameFailureThatIsNotABrokerRefusalStillRetiresItsCommand and permanentNonBrokerRenameFailureRetiresItsCommandRow both pin the same single branch -- renameSession's catch fall-through to abandonSessionMutation plus dependencyUnavailable("hosted_harness_unavailable") -- with the same three assertions. They were distinct while the catch still classified DaemonHttpException(<500) and IllegalStateException separately; retiring the row on every answered failure collapsed them. The deleted twin also stubbed its DaemonHttpException double with a bare mock(), so getStatusCode() returned 0 rather than the documented 4xx, and its TransactionTemplate wrapper mirrors no production contract (renameSession carries no @Transactional). The surviving test uses the shared boundSession/boundServiceWith helpers, which build the identical workspaceFilesEnabled configuration and the identical bound Session. The two imports only that test used are removed, as checkstyle enforces UnusedImports for this module. Not compiled or executed here: this host has no mvn and only JDK 1.8. Every removed member was grepped to zero remaining references across the module, and no surviving import was left unused. The Java lanes on the pre-push head 52704a9de0 are green and are the verifier. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmusjmlf72u --- .../qwen/code/managedagent/api/ApiModels.java | 7 -- .../managedagent/store/ManagedAgentStore.java | 2 +- .../store/ManagedWorkspaceRegistry.java | 4 - .../ManagedWorkspaceAdmissionTest.java | 80 ------------------- 4 files changed, 1 insertion(+), 92 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ApiModels.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ApiModels.java index ceeed294a53..ee7e3f39464 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ApiModels.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ApiModels.java @@ -86,13 +86,6 @@ public record SessionCapabilities( public record WebShellSessionCapabilities(boolean tasks, boolean artifacts, boolean actions, boolean workspaceTurns, boolean sessionClose, boolean sessionArchive, boolean sessionUnarchive, boolean sessionDelete) { - public WebShellSessionCapabilities(boolean tasks, boolean artifacts, boolean actions) { - this(tasks, artifacts, actions, false, false, false, false, false); - } - - public WebShellSessionCapabilities(boolean tasks, boolean actions) { - this(tasks, false, actions, false, false, false, false, false); - } } @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java index cd5a442e841..af133295ca7 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java @@ -663,7 +663,7 @@ private void requireWorkspaceCreator(SessionRecord session, String actorId) { if (!workspaces.canRead(session.tenantId(), actorId, session.workspace().getWorkspaceId())) { throw new ApiException(HttpStatus.NOT_FOUND, "session_not_found", "The Session was not found."); } - if (!workspaces.isSessionCreator(session.tenantId(), session.sessionId(), actorId)) { + if (!workspaces.createdSession(session.tenantId(), actorId, session.sessionId())) { throw new ApiException(HttpStatus.FORBIDDEN, "session_operation_forbidden", "Only the Session creator may manage it."); } diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedWorkspaceRegistry.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedWorkspaceRegistry.java index 34c8c7263ee..5665f2ffa92 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedWorkspaceRegistry.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedWorkspaceRegistry.java @@ -49,10 +49,6 @@ public boolean createdSession(String tenantId, String actorId, Integer.class, tenantId, sessionId, tenantId, key).isEmpty(); } - public boolean isSessionCreator(String tenantId, String sessionId, String actorId) { - return createdSession(tenantId, actorId, sessionId); - } - public boolean canRead(String tenantId, String actorId, String workspaceId) { if (actorId == null || actorId.isEmpty()) { diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java index ed0454ae88a..c5bf2bf95ea 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java @@ -3,14 +3,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.catchThrowable; -import static org.mockito.Mockito.mock; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.alibaba.qwen.code.daemon.DaemonHttpException; import com.alibaba.qwen.code.managedagent.api.ApiException; import com.alibaba.qwen.code.managedagent.api.ApiModels.CommandAdmission; import com.alibaba.qwen.code.managedagent.api.ApiModels.InputBlock; @@ -776,84 +774,6 @@ public void rename(String tenantId, String sessionId, sessionId)).isEqualTo("second"); } - @Test - void aRenameFailureThatIsNotABrokerRefusalStillRetiresItsCommand() { - String tenant = "tenant-" + UUID.randomUUID(); - register(tenant, "ws-a", "storage-a", - WorkspaceExecutionProfile.CONFIG_REF, - WorkspaceExecutionProfile.POLICY_REF); - grant(tenant, "ws-a", "actor-a", true); - String digest = "sha256:" + "a".repeat(64); - String sessionId = store.insertWorkspaceSessionCommand(tenant, - "actor-a", "create", digest, "qwen-code", null, null, - List.of(), null, new WorkspaceSelection("ws-a", ".")) - .sessionId(); - ManagedAgentProperties enabled = new ManagedAgentProperties(); - enabled.getHarness().setWorkspaceFilesEnabled(true); - ManagedAgentStore gated = new ManagedAgentStore(jdbc, mapper, - Clock.systemUTC(), ignored -> { - }, registry, enabled); - // A Harness that lost the Session answers the rename with a 4xx, - // which the client surfaces as a DaemonHttpException — a permanent - // failure, but not a broker refusal. - DaemonHttpException lost = mock(DaemonHttpException.class); - UnavailableHarnessConnector harness = - new UnavailableHarnessConnector() { - private int renames; - - @Override - public boolean isAvailable() { - return true; - } - - @Override - public boolean isWorkspaceFilesAvailable() { - return true; - } - - @Override - public Attachment createOrLoad(String tenantId, - String sessionId, boolean loadExisting) { - return new Attachment("boot"); - } - - @Override - public void rename(String tenantId, String sessionId, - String title) { - if (renames++ == 0) { - throw lost; - } - } - }; - ManagedAgentService service = new ManagedAgentService(gated, - new RequestDigests(), null, harness, registry); - TransactionTemplate transaction = new TransactionTemplate( - transactionManager); - - transaction.executeWithoutResult(status -> - assertThatThrownBy(() -> service.renameSession(tenant, - "actor-a", "rename-1", sessionId, "first")) - .isInstanceOfSatisfying(ApiException.class, error -> { - assertThat(error.getStatus()) - .isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); - assertThat(error.getCode()) - .isEqualTo("hosted_harness_unavailable"); - })); - // The answered mutation retired its command row too, so a fresh key - // is admitted instead of wedging on session_operation_active. - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM" - + " managed_agent_command WHERE tenant_id = ?" - + " AND session_id = ? AND command_status = 'PENDING'", - Integer.class, tenant, sessionId)).isZero(); - - transaction.executeWithoutResult(status -> service.renameSession( - tenant, "actor-a", "rename-2", sessionId, "second")); - assertThat(jdbc.queryForObject("SELECT title FROM" - + " managed_agent_session WHERE tenant_id = ?" - + " AND session_id = ?", String.class, tenant, - sessionId)).isEqualTo("second"); - } - @Test void enabledCreationRefusesPolicyDriftAndAnotherTenantsMount() { String tenant = "tenant-" + UUID.randomUUID(); From f8e83fc7707ed91eaea49399252a4c6606f1b28f Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 4 Oct 2026 00:20:28 +0800 Subject: [PATCH 52/73] fix(managed-agent): dedupe by dropping this PR's own added test, not main's Corrects 8d98903588, which deleted the wrong twin. aRenameFailureThatIsNotABrokerRefusalStillRetiresItsCommand is pre-existing main coverage: it sits at line 776 of ManagedWorkspaceAdmissionTest.java both at this PR's merge base 2a6879e648 and on origin/main, introduced by #13112 (2b15eac862), which is an ancestor of the merge base. Removing it made this PR delete 80 lines of unrelated main test coverage. permanentNonBrokerRenameFailureRetiresItsCommandRow is the twin this PR added, and it is the one that duplicates main's test: both drive the same single branch (renameSession's catch fall-through to abandonSessionMutation plus dependencyUnavailable("hosted_harness_unavailable")) with the same three assertions. They were only distinct while the catch classified DaemonHttpException(<500) and IllegalStateException separately; retiring the row on every answered failure collapsed them. So the deletion moves to the PR's own addition. The test file's diff against the merge base is now 233 insertions and zero deletions instead of 291/80. The two imports 8d98903588 removed (DaemonHttpException, Mockito.mock) are restored, as main's test still uses both; no import is left unused, which this module's checkstyle enforces. The R2-3 (unused WebShellSessionCapabilities constructors) and R2-4 (isSessionCreator wrapper with reversed String parameters) deletions from 8d98903588 are unchanged and unaffected. Not compiled or executed here: this host has no mvn and only JDK 1.8. The removed method was grepped to zero remaining references and the deletion is brace-balanced; the Java lanes are the verifier. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmusjmlf72u --- .../ManagedWorkspaceAdmissionTest.java | 138 ++++++++++-------- 1 file changed, 80 insertions(+), 58 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java index c5bf2bf95ea..e2538c68323 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java @@ -3,12 +3,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.catchThrowable; +import static org.mockito.Mockito.mock; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.alibaba.qwen.code.daemon.DaemonHttpException; import com.alibaba.qwen.code.managedagent.api.ApiException; import com.alibaba.qwen.code.managedagent.api.ApiModels.CommandAdmission; import com.alibaba.qwen.code.managedagent.api.ApiModels.InputBlock; @@ -774,6 +776,84 @@ public void rename(String tenantId, String sessionId, sessionId)).isEqualTo("second"); } + @Test + void aRenameFailureThatIsNotABrokerRefusalStillRetiresItsCommand() { + String tenant = "tenant-" + UUID.randomUUID(); + register(tenant, "ws-a", "storage-a", + WorkspaceExecutionProfile.CONFIG_REF, + WorkspaceExecutionProfile.POLICY_REF); + grant(tenant, "ws-a", "actor-a", true); + String digest = "sha256:" + "a".repeat(64); + String sessionId = store.insertWorkspaceSessionCommand(tenant, + "actor-a", "create", digest, "qwen-code", null, null, + List.of(), null, new WorkspaceSelection("ws-a", ".")) + .sessionId(); + ManagedAgentProperties enabled = new ManagedAgentProperties(); + enabled.getHarness().setWorkspaceFilesEnabled(true); + ManagedAgentStore gated = new ManagedAgentStore(jdbc, mapper, + Clock.systemUTC(), ignored -> { + }, registry, enabled); + // A Harness that lost the Session answers the rename with a 4xx, + // which the client surfaces as a DaemonHttpException — a permanent + // failure, but not a broker refusal. + DaemonHttpException lost = mock(DaemonHttpException.class); + UnavailableHarnessConnector harness = + new UnavailableHarnessConnector() { + private int renames; + + @Override + public boolean isAvailable() { + return true; + } + + @Override + public boolean isWorkspaceFilesAvailable() { + return true; + } + + @Override + public Attachment createOrLoad(String tenantId, + String sessionId, boolean loadExisting) { + return new Attachment("boot"); + } + + @Override + public void rename(String tenantId, String sessionId, + String title) { + if (renames++ == 0) { + throw lost; + } + } + }; + ManagedAgentService service = new ManagedAgentService(gated, + new RequestDigests(), null, harness, registry); + TransactionTemplate transaction = new TransactionTemplate( + transactionManager); + + transaction.executeWithoutResult(status -> + assertThatThrownBy(() -> service.renameSession(tenant, + "actor-a", "rename-1", sessionId, "first")) + .isInstanceOfSatisfying(ApiException.class, error -> { + assertThat(error.getStatus()) + .isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(error.getCode()) + .isEqualTo("hosted_harness_unavailable"); + })); + // The answered mutation retired its command row too, so a fresh key + // is admitted instead of wedging on session_operation_active. + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM" + + " managed_agent_command WHERE tenant_id = ?" + + " AND session_id = ? AND command_status = 'PENDING'", + Integer.class, tenant, sessionId)).isZero(); + + transaction.executeWithoutResult(status -> service.renameSession( + tenant, "actor-a", "rename-2", sessionId, "second")); + assertThat(jdbc.queryForObject("SELECT title FROM" + + " managed_agent_session WHERE tenant_id = ?" + + " AND session_id = ?", String.class, tenant, + sessionId)).isEqualTo("second"); + } + @Test void enabledCreationRefusesPolicyDriftAndAnotherTenantsMount() { String tenant = "tenant-" + UUID.randomUUID(); @@ -1191,64 +1271,6 @@ void sameKeyRenameReplaysTheRecordedOutcomeAfterReRegistration() { .containsEntry("title", "renamed title"); } - @Test - void permanentNonBrokerRenameFailureRetiresItsCommandRow() { - String tenant = "tenant-" + UUID.randomUUID(); - String sessionId = boundSession(tenant); - // The connector's approval-mode IllegalStateException is a permanent - // failure that is not a RuntimeBrokerException: it must still retire - // the PENDING row, or every fresh-key rename wedges on - // session_operation_active for the Session's life. - UnavailableHarnessConnector harness = - new UnavailableHarnessConnector() { - private int attaches; - - @Override - public boolean isAvailable() { - return true; - } - - @Override - public boolean isWorkspaceFilesAvailable() { - return true; - } - - @Override - public Attachment createOrLoad(String tenantId, - String sessionId, boolean loadExisting) { - if (attaches++ == 0) { - throw new IllegalStateException( - "Hosted Harness did not confirm the Session approval mode"); - } - return new Attachment("boot"); - } - - @Override - public void rename(String tenantId, String sessionId, - String title) { - } - }; - ManagedAgentService service = boundServiceWith(harness); - - assertThatThrownBy(() -> service.renameSession(tenant, "actor-a", - "rename-1", sessionId, "first")) - .isInstanceOfSatisfying(ApiException.class, error -> { - assertThat(error.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); - assertThat(error.getCode()) - .isEqualTo("hosted_harness_unavailable"); - }); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM" - + " managed_agent_command WHERE tenant_id = ? AND" - + " command_status = 'PENDING'", Integer.class, tenant)) - .isZero(); - - var retried = service.renameSession(tenant, "actor-a", "rename-2", - sessionId, "second"); - assertThat(retried.replayed()).isFalse(); - assertThat(retried.body().metadata()) - .containsEntry("title", "second"); - } - private ManagedAgentService boundServiceWithWorkingHarness() { return boundServiceWith(new UnavailableHarnessConnector() { @Override From a18ea0e422490ab7d9498b267f2d891c05f304d6 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 4 Oct 2026 11:12:49 +0800 Subject: [PATCH 53/73] fix(serve): retain passive recovery leases on refusal --- .../src/serve/hosted-harness-session.test.ts | 134 +++++++++++++++++- .../cli/src/serve/hosted-harness-session.ts | 15 +- .../cli/src/serve/hosted-runtime-recovery.ts | 9 +- 3 files changed, 146 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/serve/hosted-harness-session.test.ts b/packages/cli/src/serve/hosted-harness-session.test.ts index 4ce0e84817b..23b013a3bd4 100644 --- a/packages/cli/src/serve/hosted-harness-session.test.ts +++ b/packages/cli/src/serve/hosted-harness-session.test.ts @@ -6488,7 +6488,7 @@ describe('Hosted Harness Runtime turn takeover', () => { * execute hangs until the owner is "crashed" via cancel, leaving the turn * unsettled in the journal. */ - async function parkToolTurn() { + async function parkToolTurn(retainOwner = false) { vi.spyOn(HostedWorkspaceBroker.prototype, 'warm').mockResolvedValue(); acquireSpy = vi .spyOn(HostedWorkspaceBroker.prototype, 'acquire') @@ -6561,13 +6561,16 @@ describe('Hosted Harness Runtime turn takeover', () => { }, { timeout: 10_000 }, ); - const closed = await headers( - supertest(server).delete(`/session/${SESSION_ID}`), - ); - expect(closed.status).toBe(204); + if (!retainOwner) { + const closed = await headers( + supertest(server).delete(`/session/${SESSION_ID}`), + ); + expect(closed.status).toBe(204); + } // Only the parked owner's own acquires are behind us; a takeover's // acquire must be visible to the asserting test. acquireSpy.mockClear(); + return { server, clientId: created.body.clientId as string }; } async function loadReplacement(passive = false) { @@ -6791,6 +6794,127 @@ describe('Hosted Harness Runtime turn takeover', () => { ); }); + function failFinalAuthorization(failure: 'blocked' | 'exception') { + const original = + LocalManagedSessionAuthority.prototype.harnessRunAuthorization; + const acquisitionsBefore = acquireSpy.mock.calls.length; + let failed = false; + return vi + .spyOn(LocalManagedSessionAuthority.prototype, 'harnessRunAuthorization') + .mockImplementation(async function (this: LocalManagedSessionAuthority) { + if (acquireSpy.mock.calls.length > acquisitionsBefore && !failed) { + failed = true; + if (failure === 'exception') throw new Error('store hiccup'); + return { status: 'blocked', reason: 'missing_state' } as never; + } + return original.call(this); + }); + } + + it.each( + (['blocked', 'exception'] as const).flatMap((failure) => + [false, true].map((retry) => ({ failure, retry })), + ), + )( + 'releases a resident passive adoption after $failure, retry=$retry', + async ({ failure, retry }) => { + const { server, clientId } = await parkToolTurn(true); + vi.spyOn(HostedWorkspaceBroker.prototype, 'status').mockResolvedValue({ + state: 'prepared', + }); + const release = vi.mocked(HostedWorkspaceBroker.prototype.release); + release.mockClear(); + const stderr = vi + .spyOn(stdio, 'writeStderrLineSafe') + .mockImplementation(() => undefined); + const owedLines = () => + stderr.mock.calls.filter( + ([line]) => line.includes('stays owed') && line.includes(PROMPT_ID), + ); + const passiveLoad = () => + headers(supertest(server).post(`/session/${SESSION_ID}/load`)).send({ + managedSessionStore: store(), + toolProfile: FILE_PROFILE, + passiveManagedRuntimeRecovery: true, + }); + const authorization = failFinalAuthorization(failure); + const refused = await passiveLoad(); + expect(refused.status).toBe(409); + expect(refused.body.code).toBe('hosted_turn_recovery_required'); + expect(acquireSpy).toHaveBeenCalledOnce(); + expect(authorization).toHaveBeenCalledTimes(2); + expect(release).not.toHaveBeenCalled(); + const firstRefusalDiagnostics = owedLines().length; + let repeatedRefusalDiagnostics: number | undefined; + authorization.mockRestore(); + if (retry) { + const loaded = await passiveLoad(); + expect(loaded.status).toBe(200); + expect(loaded.body.clientId).toBe(clientId); + expect(acquireSpy).toHaveBeenCalledTimes(2); + expect(release).not.toHaveBeenCalled(); + // Successful attachment drains the refusal's diagnostic record. + const failedAgain = failFinalAuthorization(failure); + expect((await passiveLoad()).status).toBe(409); + repeatedRefusalDiagnostics = owedLines().length; + failedAgain.mockRestore(); + } + const closed = await headers( + supertest(server).delete(`/session/${SESSION_ID}`), + ); + expect(closed.status).toBe(204); + expect(release).toHaveBeenCalledOnce(); + expect( + (release.mock.contexts[0] as HostedWorkspaceBroker).runtimeSessionId, + ).toBe(PROMPT_ID); + expect(firstRefusalDiagnostics).toBe(1); + if (retry) expect(repeatedRefusalDiagnostics).toBe(2); + }, + ); + + it.each(['blocked', 'exception'] as const)( + 'records a cold passive adoption after final authorization is %s', + async (failure) => { + await parkToolTurn(); + vi.spyOn(HostedWorkspaceBroker.prototype, 'status').mockResolvedValue({ + state: 'prepared', + }); + const release = vi.mocked(HostedWorkspaceBroker.prototype.release); + release.mockClear(); + const stderr = vi + .spyOn(stdio, 'writeStderrLineSafe') + .mockImplementation(() => undefined); + const authorization = failFinalAuthorization(failure); + const { server, loaded } = await loadReplacement(true); + expect(loaded.status).toBe(409); + expect(loaded.body.code).toBe('hosted_turn_recovery_required'); + expect(acquireSpy).toHaveBeenCalledOnce(); + expect(release).not.toHaveBeenCalled(); + expect( + stderr.mock.calls.some( + ([line]) => line.includes('stays owed') && line.includes(PROMPT_ID), + ), + ).toBe(true); + authorization.mockRestore(); + const reloaded = await replacementHeaders( + supertest(server).post(`/session/${SESSION_ID}/load`), + ).send({ + managedSessionStore: storeFor(BOOT_ID_2), + toolProfile: FILE_PROFILE, + passiveManagedRuntimeRecovery: true, + }); + expect(reloaded.status).toBe(200); + expect(release).not.toHaveBeenCalled(); + await replacementHeaders( + supertest(server).delete(`/session/${SESSION_ID}`), + ).expect(204); + expect(release).toHaveBeenCalledOnce(); + expect( + (release.mock.contexts[0] as HostedWorkspaceBroker).runtimeSessionId, + ).toBe(PROMPT_ID); + }, + ); + it('reports a parked execution passively and cancels the turn', async () => { await parkToolTurn(); let stopConfirmed = false; diff --git a/packages/cli/src/serve/hosted-harness-session.ts b/packages/cli/src/serve/hosted-harness-session.ts index 1811d51c017..ec481cf0a66 100644 --- a/packages/cli/src/serve/hosted-harness-session.ts +++ b/packages/cli/src/serve/hosted-harness-session.ts @@ -1445,12 +1445,11 @@ export function registerHostedHarnessSessionRoutes( promptId: parked, brokerOptions, passive: true, + onPassiveRuntimeAcquired: (runtimeSessionId) => { + resident.runtimeLeaseHeld = runtimeSessionId; + }, }); recovery = recovered?.report; - if (recovered?.acquiredRuntime) - resident.runtimeLeaseHeld = - recovered.report.executions[0]?.runtimeSessionId ?? - recovered.promptId; if ( !resident.active && unsettledPromptId(resident) && @@ -1461,6 +1460,7 @@ export function registerHostedHarnessSessionRoutes( recovery.activationId !== resident.managed.activation.activationId) ) { + noteOwedAdoption(resident, sessionId); error(res, 409, 'hosted_turn_recovery_required'); return; } @@ -1473,6 +1473,7 @@ export function registerHostedHarnessSessionRoutes( error(res, 409, 'hosted_session_closing'); return; } + refusedAdoptions.delete(sessionId); sendAttachment( res, sessionId, @@ -1482,6 +1483,7 @@ export function registerHostedHarnessSessionRoutes( : recovery, ); } catch { + noteOwedAdoption(resident, sessionId); error(res, 409, 'hosted_turn_recovery_required'); } return; @@ -1731,8 +1733,12 @@ export function registerHostedHarnessSessionRoutes( promptId: unsettled, brokerOptions, passive: body?.['passiveManagedRuntimeRecovery'] === true, + onPassiveRuntimeAcquired: (runtimeSessionId) => { + session.runtimeLeaseHeld = runtimeSessionId; + }, }); if (recovered === undefined) { + noteOwedAdoption(session, sessionId); await managed.close(); error(res, 409, 'hosted_turn_recovery_required'); return; @@ -1743,6 +1749,7 @@ export function registerHostedHarnessSessionRoutes( recovered.report.executions[0]?.runtimeSessionId ?? recovered.promptId; } catch (cause) { + noteOwedAdoption(session, sessionId); await managed.close(); writeStderrLineSafe( `qwen serve: Hosted Harness recovery of session ${sessionId} failed: ${String(cause)}`, diff --git a/packages/cli/src/serve/hosted-runtime-recovery.ts b/packages/cli/src/serve/hosted-runtime-recovery.ts index 81b8b8f9f52..b1fc62d2f1c 100644 --- a/packages/cli/src/serve/hosted-runtime-recovery.ts +++ b/packages/cli/src/serve/hosted-runtime-recovery.ts @@ -285,8 +285,9 @@ export async function stopParkedRuntimeExecutions(input: { * original `executionCallId` — the Broker's durable record keeps that * exactly-once — commits the tool results and lets the checkpoint reach * `results_ready` before the caller answers. A passive load adopts the - * dead owner's Runtime Session and reads execution states for the - * cancellation path; it never dispatches. + * original Runtime Session and reads execution states for the cancellation + * path; it never dispatches. It reports that adoption before fallible reads, + * so the caller retains the lease even when no recovery report returns. * * Returns undefined when this is not a Runtime wait the session can take over; * the caller then keeps its plain refusal. @@ -298,6 +299,7 @@ export async function recoverHostedRuntimeTurn(input: { promptId: string; brokerOptions: HostedWorkspaceBrokerOptions; passive: boolean; + onPassiveRuntimeAcquired?: (runtimeSessionId: string) => void; }): Promise { const { session, promptId, passive } = input; const authorization = await session.authority.harnessRunAuthorization(); @@ -336,7 +338,7 @@ export async function recoverHostedRuntimeTurn(input: { // The passive path never compensation-releases: a release persists the // record as RELEASED, every retried acquire of the same identity then // conflicts with 409 runtime_session_not_acquirable, and a load that - // throws leaves no harness-side record a route could hand back. The + // throws before registration leaves no owner a route could hand back. The // adoption instead stays owed to the retried takeover, which re-acquires // a READY session under the same identity idempotently server-side; a // load that reports successfully hands the lease to the cancel route. @@ -345,6 +347,7 @@ export async function recoverHostedRuntimeTurn(input: { // follow-up, not settled by this change. await broker.acquire(); acquiredRuntime = true; + input.onPassiveRuntimeAcquired?.(broker.runtimeSessionId); } if (pending.length > 0) { if (passive) { From 30f092d0984b2dc52e946873f629b15a46b4e22f Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 4 Oct 2026 23:41:51 +0800 Subject: [PATCH 54/73] fix(managed-agent): keep the re-registration guard on the page twin The page refactor on main rewired webShellSession(session, actorId) to the batch assembler, whose maySubmitWorkspaceTurn twin answers the creator submit gate from the batched grant read alone. That read carried no binding stamp, so a re-registered Workspace kept advertising workspace_turns on the page and on the single-session path while the singular gate still refused the submit through bindingCurrent. Carry workspace_generation and storage_id in the batch grant read and compare them with the Session's recorded binding, so the twin answers the same rule without a per-row registry query: the grant batch stays the one managed_workspace_registry read the page budget asserts. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-conflict/jmutypl6357 --- .../service/ManagedAgentService.java | 17 +++++--- .../store/ManagedWorkspaceRegistry.java | 41 +++++++++++++++---- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java index 186a205ffb9..faa5e02a32b 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java @@ -661,7 +661,7 @@ private List webShellSessions( session.sessionId())) .map(session -> session.workspace().getWorkspaceId()) .collect(java.util.stream.Collectors.toSet()); - Map grants = + Map grants = grantWorkspaces.isEmpty() ? Map.of() : workspaces.findReadable(tenantId, actorId, grantWorkspaces); @@ -908,16 +908,23 @@ private boolean maySubmitWorkspaceTurn(SessionRecord session, } // The page twin of the singular: the same rule answered from the batch - // reads the assembler already made. + // reads the assembler already made. The grant batch carries the registry's + // binding stamp, so a re-registration drops the capability here exactly as + // bindingCurrent drops it in the singular. private boolean maySubmitWorkspaceTurn(SessionRecord session, Set creatorOwns, - Map grants) { + Map grants) { if (!maySubmitShape(session) || !creatorOwns.contains(session.sessionId())) { return false; } - var summary = grants.get(session.workspace().getWorkspaceId()); - return summary != null && summary.canCreateSession(); + var grant = grants.get(session.workspace().getWorkspaceId()); + if (grant == null || !grant.canCreateSession()) { + return false; + } + var binding = session.workspace(); + return grant.workspaceGeneration() == binding.getWorkspaceGeneration() + && grant.storageId().equals(binding.getStorageId()); } private boolean maySubmitShape(SessionRecord session) { diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedWorkspaceRegistry.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedWorkspaceRegistry.java index 0f21e501c31..42e9fcbfba2 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedWorkspaceRegistry.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedWorkspaceRegistry.java @@ -150,8 +150,13 @@ public WorkspaceSummary findReadable(String tenantId, String actorId, return rows.isEmpty() ? null : rows.getFirst(); } - /** The batch twin of findReadable for a page of Workspace ids. */ - public java.util.Map findReadable( + /** + * The batch twin of findReadable for a page of Workspace ids. It carries + * the registry's current binding stamp as well, so a page answers the + * creator-submit gate from this one read instead of a per-row + * bindingCurrent. + */ + public java.util.Map findReadable( String tenantId, String actorId, java.util.Collection workspaceIds) { if (workspaceIds.isEmpty()) { @@ -169,9 +174,10 @@ public java.util.Map findReadable( arguments.addAll(workspaceIds); arguments.add(tenantId); arguments.add(key); - List rows = jdbc.query( - "SELECT r.workspace_id, r.display_name, r.state," - + " a.can_create FROM managed_workspace_registry r" + List rows = jdbc.query( + "SELECT r.workspace_id, r.workspace_generation," + + " r.storage_id, r.state, a.can_create" + + " FROM managed_workspace_registry r" + " JOIN managed_workspace_access a ON" + " a.tenant_id = r.tenant_id" + " AND a.workspace_id = r.workspace_id" @@ -186,10 +192,10 @@ public java.util.Map findReadable( + " AND CAST(CONCAT(r.tenant_id, '!') AS BINARY(513))" + " = CAST(CONCAT(?, '!') AS BINARY(513))" + " AND a.actor_id = ? AND a.can_read = TRUE", - (result, row) -> summary(result), arguments.toArray()); - java.util.Map result = + (result, row) -> readableGrant(result), arguments.toArray()); + java.util.Map result = new java.util.HashMap<>(rows.size() * 2); - for (WorkspaceSummary row : rows) { + for (ReadableGrant row : rows) { result.put(row.workspaceId(), row); } return result; @@ -220,6 +226,16 @@ private static WorkspaceSummary summary(ResultSet result) && "ACTIVE".equals(state)); } + private static ReadableGrant readableGrant(ResultSet result) + throws SQLException { + String state = result.getString("state"); + return new ReadableGrant(result.getString("workspace_id"), + result.getLong("workspace_generation"), + result.getString("storage_id"), + result.getBoolean("can_create") + && "ACTIVE".equals(state)); + } + /** * Whether the registry still holds the Workspace generation and storage a * Session was bound to; a re-registration changes them. @@ -237,6 +253,15 @@ public record WorkspaceSummary(String workspaceId, String displayName, String state, boolean canCreateSession) { } + /** + * A readable Workspace with the generation and storage the registry holds + * now, so a batch caller can tell whether a Session's recorded binding is + * still the current one. + */ + public record ReadableGrant(String workspaceId, long workspaceGeneration, + String storageId, boolean canCreateSession) { + } + public ResolvedBinding resolveForCreation(String tenantId, String actorId, WorkspaceSelection selection) { if (!TransactionSynchronizationManager.isActualTransactionActive()) { From df8bdc567c5ce4bc10e2d084206ccb31b96662ff Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Mon, 5 Oct 2026 04:54:15 +0800 Subject: [PATCH 55/73] fix(managed-agent): keep the Session lifecycle term on cancel admission requireCanceller hand-copied only the workspace and files disjuncts of maySubmitShape, dropping the Session lifecycle term that main enforced through requireSubmitter. A cancel on a CLOSED, ARCHIVED or DELETED bound Session answered 202 and wrote a CANCEL command where main answered 409 workspace_unavailable and wrote nothing. Cite maySubmitShape instead: it restores the lifecycle term and removes the duplication. The grant and registry terms stay out of the cancel rule, so the creator can still cancel while can_create is revoked, the Workspace is draining or it was re-registered. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmuu9s91d5p --- .../service/ManagedAgentService.java | 6 ++--- .../ManagedWorkspaceAdmissionTest.java | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java index faa5e02a32b..db75fbfffea 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java @@ -865,12 +865,12 @@ private void requireBoundCreator(String tenantId, String actorId, // Cancelling aborts work that is already running, so it needs only what // identifies the creator, not the grants that admit new work: the // creator who can still read the Workspace may cancel while can_create is - // revoked, the Workspace is draining or it was re-registered. + // revoked, the Workspace is draining or it was re-registered. The shape + // term stays: a Session that can no longer execute keeps the refusal. private void requireCanceller(String tenantId, String actorId, String sessionId) { SessionRecord session = store.requireSession(tenantId, sessionId); - if (session.workspace() == null - || !harness.isWorkspaceFilesAvailable() + if (!maySubmitShape(session) || !workspaces.canRead(session.tenantId(), actorId, session.workspace().getWorkspaceId()) || !workspaces.createdSession(session.tenantId(), actorId, diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java index a054da97042..84d31357453 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java @@ -1160,6 +1160,28 @@ void creatorCancelsWithoutTheGrantsThatAdmitNewWork() { "workspace_unavailable"); } + @Test + void cancelRefusesABoundSessionThatCanNoLongerExecute() { + String tenant = "tenant-" + UUID.randomUUID(); + String sessionId = boundSession(tenant); + ManagedAgentService service = boundServiceWithWorkingHarness(); + String turnId = service.submitTurn(tenant, "actor-a", "submit-1", + sessionId, List.of(new InputBlock("text", "go"))).turnId(); + + // Cancelling cites the admission shape, so closing the Session + // restores the legacy refusal instead of writing a CANCEL command + // for a Turn that can no longer run. + jdbc.update("UPDATE managed_agent_session SET status = 'CLOSED'" + + " WHERE tenant_id = ? AND session_id = ?", + tenant, sessionId); + assertRefused(() -> service.cancelTurn(tenant, "actor-a", "cancel-1", + sessionId, turnId), "workspace_unavailable"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM" + + " managed_agent_command WHERE tenant_id = ?" + + " AND operation = 'CANCEL_TURN'", Integer.class, tenant)) + .isZero(); + } + @ParameterizedTest @ValueSource(booleans = {false, true}) void reRegistrationRefusesLaterWorkBeforeAnyCommandIsWritten(boolean storageOnly) { From b683a3d62a84e55a414c95b649995f462d16c4de Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Mon, 5 Oct 2026 08:16:58 +0800 Subject: [PATCH 56/73] fix(managed-agent): record a passive adoption stranded by a concurrent teardown The resident passive-load path answered its two post-await exits without noteOwedAdoption, even though onPassiveRuntimeAcquired had already recorded the adopted Runtime lease. close() admits a concurrent DELETE here: its guard is only active/mcpBusy/mcpRecovering/hooksBusy, none of which a parked passive recovery sets, and the route allows a missing client id. Its releaseLeaseNow then runs while runtimeLeaseHeld is still undefined, so it releases nothing, the acquire resolves afterwards, and the adopted Runtime Session is left unreachable from every route with nothing named on stderr. Record the strand on both exits rather than releasing it: a release persists the record as RELEASED and every retried acquire of the same identity then conflicts with 409 runtime_session_not_acquirable. Also drain refusedAdoptions on both resident drive-redrive success exits, matching the resident passive success exit and the record's own contract that the next successful load of the id drains it. Without the drain a later genuinely stranded adoption is suppressed by the has(sessionId) latch. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmuug7qaz61 --- .../src/serve/hosted-harness-session.test.ts | 52 +++++++++++++++++++ .../cli/src/serve/hosted-harness-session.ts | 6 +++ 2 files changed, 58 insertions(+) diff --git a/packages/cli/src/serve/hosted-harness-session.test.ts b/packages/cli/src/serve/hosted-harness-session.test.ts index b1c1cae1349..d860b3c1bf9 100644 --- a/packages/cli/src/serve/hosted-harness-session.test.ts +++ b/packages/cli/src/serve/hosted-harness-session.test.ts @@ -7211,6 +7211,58 @@ describe('Hosted Harness Runtime turn takeover', () => { }, ); + it('records the owed adoption when a teardown strands a resident passive load', async () => { + const { server } = await parkToolTurn(true); + vi.spyOn(HostedWorkspaceBroker.prototype, 'status').mockResolvedValue({ + state: 'prepared', + }); + const release = vi.mocked(HostedWorkspaceBroker.prototype.release); + release.mockClear(); + const stderr = vi + .spyOn(stdio, 'writeStderrLineSafe') + .mockImplementation(() => undefined); + const owedLines = () => + stderr.mock.calls.filter( + ([line]) => line.includes('stays owed') && line.includes(PROMPT_ID), + ); + let finishAcquire!: () => void; + const acquireGate = new Promise((resolve) => { + finishAcquire = resolve; + }); + acquireSpy.mockImplementationOnce(() => acquireGate as never); + let loading: Promise | undefined; + try { + loading = headers(supertest(server).post(`/session/${SESSION_ID}/load`)) + .send({ + managedSessionStore: store(), + toolProfile: FILE_PROFILE, + passiveManagedRuntimeRecovery: true, + }) + .then((response) => response); + await vi.waitFor(() => expect(acquireSpy).toHaveBeenCalledOnce(), { + timeout: 10_000, + }); + // A DELETE needs no client id, and close() fences an active Turn, MCP + // work and Hooks — not a parked passive recovery. It releases nothing + // (the lease is not recorded yet) and drops the Session. + await headers(supertest(server).delete(`/session/${SESSION_ID}`)).expect( + 204, + ); + finishAcquire(); + const loaded = await loading; + expect(loaded.status).toBe(404); + expect(loaded.body.code).toBe('hosted_session_not_found'); + // No route can hand the adoption back now, so it is named rather than + // released: a release would persist RELEASED and wedge every retried + // acquire of the identity. + expect(release).not.toHaveBeenCalled(); + expect(owedLines()).toHaveLength(1); + } finally { + finishAcquire(); + await loading; + } + }, 30_000); + it.each(['blocked', 'exception'] as const)( 'records a cold passive adoption after final authorization is %s', async (failure) => { diff --git a/packages/cli/src/serve/hosted-harness-session.ts b/packages/cli/src/serve/hosted-harness-session.ts index 5ffc0345fd0..dbf8401a3fe 100644 --- a/packages/cli/src/serve/hosted-harness-session.ts +++ b/packages/cli/src/serve/hosted-harness-session.ts @@ -1459,6 +1459,7 @@ export function registerHostedHarnessSessionRoutes( // No single parked Turn: the first load's answer still holds, so the // redrive gets the same attachment restated — including a blocked // Session, whose recoveryRequired the coordinator already handles. + refusedAdoptions.delete(sessionId); sendAttachment(res, sessionId, resident); return; } @@ -1499,6 +1500,7 @@ export function registerHostedHarnessSessionRoutes( error(res, 409, 'hosted_turn_recovery_required'); return; } + refusedAdoptions.delete(sessionId); sendAttachment(res, sessionId, resident, recovery); return; } @@ -1586,11 +1588,15 @@ export function registerHostedHarnessSessionRoutes( return; } } + // A teardown overlapping the await above released nothing (the lease + // was still unrecorded) and left no route to hand it back. if (sessions.get(sessionId) !== resident) { + noteOwedAdoption(resident, sessionId); error(res, 404, 'hosted_session_not_found'); return; } if (resident.mcpClosing) { + noteOwedAdoption(resident, sessionId); error(res, 409, 'hosted_session_closing'); return; } From b8f92cedb39b6843f4a16ae7c26aebb1ba99ceff Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Mon, 5 Oct 2026 21:40:32 +0800 Subject: [PATCH 57/73] fix(managed-agent): fence every resident reattach and keep its refusal retryable The resident split answered a redriven load from the same resident connection as a passive one, but only the passive branch revalidated the store identity. Hoist that fence above both branches so a foreign tenant or Workspace stays hidden and a drifted Session Store address is refused on either path. A 404 is settled as a terminal Turn failure by the only caller, which would drop an admitted cancellation on the storage-drift refusal; answer the retryable conflict instead. The drive branch also resumed from its recovery await with no post-await revalidation, so a teardown admitted during the await answered 200 with the detached Session's clientId and left the acquired lease unreachable. Mirror the passive branch's fences there and record the owed adoption. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmuva7z827l --- .../src/serve/hosted-harness-session.test.ts | 89 ++++++++++++++++--- .../cli/src/serve/hosted-harness-session.ts | 41 +++++++-- 2 files changed, 111 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/serve/hosted-harness-session.test.ts b/packages/cli/src/serve/hosted-harness-session.test.ts index c44bbad468a..eb1e4aac8d4 100644 --- a/packages/cli/src/serve/hosted-harness-session.test.ts +++ b/packages/cli/src/serve/hosted-harness-session.test.ts @@ -6407,17 +6407,27 @@ describe('Hosted Harness tool approvals', () => { it('passively reattaches a live Turn and cancels it without opening another writer', async () => { const { server, clientId, answer, status } = await waitingSession(); - for (const changed of [ - { tenantId: 'other' }, - { workspaceId: 'other' }, - { baseUrl: 'http://other-store.test' }, - ]) { - await headers(supertest(server).post(`/session/${SESSION_ID}/load`)) - .send({ + // The frozen binding fences both resident reattach branches: a foreign + // tenant or Workspace stays hidden behind a 404, while a drifted Session + // Store address refuses with the retryable conflict its only caller needs + // to retry instead of failing the Turn. + for (const [changed, expected] of [ + [{ tenantId: 'other' }, 404], + [{ workspaceId: 'other' }, 404], + [{ baseUrl: 'http://other-store.test' }, 409], + ] as const) { + for (const recovery of [ + { passiveManagedRuntimeRecovery: true }, + { driveRuntimeRecovery: true }, + ]) { + const refused = await headers( + supertest(server).post(`/session/${SESSION_ID}/load`), + ).send({ managedSessionStore: { ...store(), ...changed }, - passiveManagedRuntimeRecovery: true, - }) - .expect(404); + ...recovery, + }); + expect(refused.status).toBe(expected); + } } await headers(supertest(server).post(`/session/${SESSION_ID}/load`)) .send({ managedSessionStore: store(), toolProfile: files }) @@ -7623,6 +7633,65 @@ describe('Hosted Harness Runtime turn takeover', () => { } }, 30_000); + it('records the owed adoption when a teardown strands a drive redrive', async () => { + await parkToolTurn(); + vi.spyOn(HostedWorkspaceBroker.prototype, 'status').mockResolvedValue({ + state: 'prepared', + }); + vi.spyOn(HostedWorkspaceBroker.prototype, 'execute').mockResolvedValue({ + executionStatus: 'success', + responseParts: [{ text: 'written' }], + } as never); + const release = vi.mocked(HostedWorkspaceBroker.prototype.release); + const stderr = vi + .spyOn(stdio, 'writeStderrLineSafe') + .mockImplementation(() => undefined); + const owedLines = () => + stderr.mock.calls.filter( + ([line]) => line.includes('stays owed') && line.includes(PROMPT_ID), + ); + const { server, loaded } = await loadReplacement(); + expect(loaded.status).toBe(200); + release.mockClear(); + let finishAcquire!: () => void; + const acquireGate = new Promise((resolve) => { + finishAcquire = resolve; + }); + acquireSpy.mockImplementationOnce(() => acquireGate as never); + let redriving: Promise | undefined; + try { + redriving = replacementHeaders( + supertest(server).post(`/session/${SESSION_ID}/load`), + ) + .send({ + managedSessionStore: storeFor(BOOT_ID_2), + toolProfile: FILE_PROFILE, + driveRuntimeRecovery: true, + }) + .then((response) => response); + await vi.waitFor(() => expect(acquireSpy).toHaveBeenCalledOnce(), { + timeout: 10_000, + }); + // The close route fences an active Turn, MCP work and Hooks — not a + // parked redrive whose await has not recorded its lease yet. + await replacementHeaders( + supertest(server).delete(`/session/${SESSION_ID}`), + ).expect(204); + finishAcquire(); + const redriven = await redriving; + expect(redriven.status).toBe(404); + expect(redriven.body.code).toBe('hosted_session_not_found'); + // The teardown handed the lease it could see; the redrive's own + // adoption is named instead of released, because a release would + // persist RELEASED and wedge every retried acquire of the identity. + expect(release).toHaveBeenCalledOnce(); + expect(owedLines()).toHaveLength(1); + } finally { + finishAcquire(); + await redriving; + } + }, 30_000); + it.each(['blocked', 'exception'] as const)( 'records a cold passive adoption after final authorization is %s', async (failure) => { diff --git a/packages/cli/src/serve/hosted-harness-session.ts b/packages/cli/src/serve/hosted-harness-session.ts index 63c5d5a14bc..506fbaf78fd 100644 --- a/packages/cli/src/serve/hosted-harness-session.ts +++ b/packages/cli/src/serve/hosted-harness-session.ts @@ -1453,6 +1453,26 @@ export function registerHostedHarnessSessionRoutes( const resident = sessions.get(sessionId); const passiveRecovery = body?.['passiveManagedRuntimeRecovery'] === true; const driveRecovery = body?.['driveRuntimeRecovery'] === true; + // Both resident reattach branches below answer from the same connection, + // so the frozen binding is checked once, before either branch runs. A + // foreign tenant or Workspace stays hidden behind a 404; a drifted + // Session Store address is the caller's own lookup failing, and the one + // caller settles a 404 as a terminal Turn failure, so it answers the + // retryable conflict instead. + if (resident !== undefined && !create) { + const key = resident.managed.authority.sessionHeader.sessionKey; + if ( + key.tenantId !== store.tenantId || + key.workspaceId !== store.workspaceId + ) { + error(res, 404, 'hosted_session_not_found'); + return; + } + if (resident.storeBaseUrl !== store.baseUrl) { + error(res, 409, 'hosted_session_store_mismatch'); + return; + } + } if ( opening.has(sessionId) || (resident && (create || (!passiveRecovery && !driveRecovery))) @@ -1520,20 +1540,23 @@ export function registerHostedHarnessSessionRoutes( error(res, 409, 'hosted_turn_recovery_required'); return; } + // A teardown admitted during the await above released nothing (the + // lease was not recorded yet) and left no route to hand it back. + if (sessions.get(sessionId) !== resident) { + noteOwedAdoption(resident, sessionId); + error(res, 404, 'hosted_session_not_found'); + return; + } + if (resident.mcpClosing) { + noteOwedAdoption(resident, sessionId); + error(res, 409, 'hosted_session_closing'); + return; + } refusedAdoptions.delete(sessionId); sendAttachment(res, sessionId, resident, recovery); return; } if (resident) { - const key = resident.managed.authority.sessionHeader.sessionKey; - if ( - key.tenantId !== store.tenantId || - key.workspaceId !== store.workspaceId || - resident.storeBaseUrl !== store.baseUrl - ) { - error(res, 404, 'hosted_session_not_found'); - return; - } if ( toolProfile === undefined && (resident.toolProfile === HOSTED_WORKSPACE_FILE_PROFILE || From b6eff8f4b043dafd6c130546ba5b6bd7c487467e Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Tue, 6 Oct 2026 04:49:05 +0800 Subject: [PATCH 58/73] fix(managed-agent): fence the parked passive recovery against teardown The resident passive load publishes its adopted Runtime lease onto the route-reachable Session the moment broker.acquire() resolves, while the recovery is still reading execution states. close() fenced only active/mcpBusy/mcpRecovering/hooksBusy, none of which a parked passive recovery set, and DELETE /session/:id resolves with a missing client id allowed, so a teardown landing in that window released a lease that was still mid-adoption. The release persists the record RELEASED, and RuntimeBrokerService.acquireNewSession answers 409 runtime_session_not_acquirable for any stored state that is neither READY nor ACQUIRING, so every later cold takeover and every cancel-path status/cancel for that parked Turn failed and the Workspace stayed pinned. Hold close()'s own fence for the whole recovery rather than recording the strand afterwards: the teardown is refused with 409 hosted_turn_active, nothing is released mid-adoption, and the adopted lease goes back with the teardown the fence lets through. The pre-diff teardown released nothing but left the adoption unreachable from every route; the fence removes that window too, so the two post-await exits no longer carry a false comment. Also states the deployment's Workspace-files opt-in in the README's authoritative cancel rule: requireCanceller reaches maySubmitShape, which returns false the moment harness.isWorkspaceFilesAvailable() is false, before falling through to requireLegacyWorkspace, which answers workspace_unavailable for a bound Session. The other contract artifacts already state the condition. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmuvp83ny8e --- .../src/serve/hosted-harness-session.test.ts | 79 ++++++++++++++++--- .../cli/src/serve/hosted-harness-session.ts | 11 ++- .../sdk-java/managed-agent-server/README.md | 3 +- 3 files changed, 78 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/serve/hosted-harness-session.test.ts b/packages/cli/src/serve/hosted-harness-session.test.ts index 868029537b5..d4cec4ebdb4 100644 --- a/packages/cli/src/serve/hosted-harness-session.test.ts +++ b/packages/cli/src/serve/hosted-harness-session.test.ts @@ -7583,7 +7583,7 @@ describe('Hosted Harness Runtime turn takeover', () => { }, ); - it('records the owed adoption when a teardown strands a resident passive load', async () => { + it('refuses a teardown that lands while a resident passive load is adopting', async () => { const { server } = await parkToolTurn(true); vi.spyOn(HostedWorkspaceBroker.prototype, 'status').mockResolvedValue({ state: 'prepared', @@ -7614,27 +7614,82 @@ describe('Hosted Harness Runtime turn takeover', () => { await vi.waitFor(() => expect(acquireSpy).toHaveBeenCalledOnce(), { timeout: 10_000, }); - // A DELETE needs no client id, and close() fences an active Turn, MCP - // work and Hooks — not a parked passive recovery. It releases nothing - // (the lease is not recorded yet) and drops the Session. - await headers(supertest(server).delete(`/session/${SESSION_ID}`)).expect( - 204, + // The parked recovery holds close()'s fence for its whole await, so a + // DELETE without a client id is refused instead of racing the adoption: + // releasing mid-adoption persists the record RELEASED and every later + // acquire of that identity answers 409 runtime_session_not_acquirable. + const closed = await headers( + supertest(server).delete(`/session/${SESSION_ID}`), ); + expect(closed.status).toBe(409); + expect(closed.body.code).toBe('hosted_turn_active'); finishAcquire(); const loaded = await loading; - expect(loaded.status).toBe(404); - expect(loaded.body.code).toBe('hosted_session_not_found'); - // No route can hand the adoption back now, so it is named rather than - // released: a release would persist RELEASED and wedge every retried - // acquire of the identity. + expect(loaded.status).toBe(200); expect(release).not.toHaveBeenCalled(); - expect(owedLines()).toHaveLength(1); + expect(owedLines()).toHaveLength(0); } finally { finishAcquire(); await loading; } }, 30_000); + it('refuses a teardown that lands after a resident passive load published its adoption', async () => { + const { server } = await parkToolTurn(true); + const release = vi.mocked(HostedWorkspaceBroker.prototype.release); + release.mockClear(); + const stderr = vi + .spyOn(stdio, 'writeStderrLineSafe') + .mockImplementation(() => undefined); + const owedLines = () => + stderr.mock.calls.filter( + ([line]) => line.includes('stays owed') && line.includes(PROMPT_ID), + ); + // onPassiveRuntimeAcquired publishes the adopted lease as soon as acquire + // resolves, and the recovery reads execution states after that: gating the + // read holds the teardown in the published-but-unfinished window. + const status = vi + .spyOn(HostedWorkspaceBroker.prototype, 'status') + .mockResolvedValue({ state: 'prepared' }); + let finishStatus!: () => void; + const statusGate = new Promise((resolve) => { + finishStatus = resolve; + }); + status.mockImplementationOnce(async () => { + await statusGate; + return { state: 'prepared' }; + }); + let loading: Promise | undefined; + try { + loading = headers(supertest(server).post(`/session/${SESSION_ID}/load`)) + .send({ + managedSessionStore: store(), + toolProfile: FILE_PROFILE, + passiveManagedRuntimeRecovery: true, + }) + .then((response) => response); + await vi.waitFor(() => expect(status).toHaveBeenCalledOnce(), { + timeout: 10_000, + }); + const closed = await headers( + supertest(server).delete(`/session/${SESSION_ID}`), + ); + expect(closed.status).toBe(409); + expect(closed.body.code).toBe('hosted_turn_active'); + finishStatus(); + const loaded = await loading; + expect(loaded.status).toBe(200); + // The adopted lease stays on the live Session: releasing it here would + // answer 409 runtime_session_not_acquirable to every later acquire of + // the same identity. + expect(release).not.toHaveBeenCalled(); + expect(owedLines()).toHaveLength(0); + } finally { + finishStatus(); + await loading; + } + }, 30_000); + it('records the owed adoption when a teardown strands a drive redrive', async () => { await parkToolTurn(); vi.spyOn(HostedWorkspaceBroker.prototype, 'status').mockResolvedValue({ diff --git a/packages/cli/src/serve/hosted-harness-session.ts b/packages/cli/src/serve/hosted-harness-session.ts index 506fbaf78fd..06e80f3f90c 100644 --- a/packages/cli/src/serve/hosted-harness-session.ts +++ b/packages/cli/src/serve/hosted-harness-session.ts @@ -1604,6 +1604,11 @@ export function registerHostedHarnessSessionRoutes( error(res, 409, 'hosted_turn_recovery_required'); return; } + // The adoption this await publishes lands on a Session every route + // can still reach, so the recovery holds close()'s own fence: a + // concurrent teardown would otherwise release a lease that is still + // mid-adoption and persist the record RELEASED. + resident.mcpRecovering = true; const recovered = await recoverHostedRuntimeTurn({ session: resident.managed, sessionId, @@ -1614,6 +1619,8 @@ export function registerHostedHarnessSessionRoutes( onPassiveRuntimeAcquired: (runtimeSessionId) => { resident.runtimeLeaseHeld = runtimeSessionId; }, + }).finally(() => { + resident.mcpRecovering = false; }); recovery = recovered?.report; if ( @@ -1631,8 +1638,8 @@ export function registerHostedHarnessSessionRoutes( return; } } - // A teardown overlapping the await above released nothing (the lease - // was still unrecorded) and left no route to hand it back. + // The fence above covers the whole adoption, so these exits answer + // only a Session another route already dropped. if (sessions.get(sessionId) !== resident) { noteOwedAdoption(resident, sessionId); error(res, 404, 'hosted_session_not_found'); diff --git a/packages/sdk-java/managed-agent-server/README.md b/packages/sdk-java/managed-agent-server/README.md index 3c7cae69c44..2f9ce68e8e9 100644 --- a/packages/sdk-java/managed-agent-server/README.md +++ b/packages/sdk-java/managed-agent-server/README.md @@ -652,7 +652,8 @@ opt-in while they can still read and create in the Workspace (the per-caller registry's `ACTIVE` state and the Workspace generation and storage the Session was bound to); the creator may also rename the Session. Cancelling aborts work that is already running, so the creator may cancel a running Turn while they can still -read the Workspace, even after their create grant is revoked, the Workspace +read the Workspace and the deployment still enables Workspace files, even after +their create grant is revoked, the Workspace starts draining or it is re-registered. A live cancel reuses the owner's resident Harness attachment. A cold connector cache passively re-attaches for the persisted cancellation after checking the frozen Session binding and exact From d7aa13aab9e1790e10b88088480f3e402f27ac85 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 7 Oct 2026 00:18:39 +0800 Subject: [PATCH 59/73] fix(managed-agent): preserve concurrent recovery and rename ordering Count all pending passive recovery requests before permitting teardown. Persist the newest mutation attempt boundary so a failing sibling does not reject its successful retry. Co-authored-by: Qwen-Coder --- .../src/serve/hosted-harness-session.test.ts | 77 +++++++++++++++++++ .../cli/src/serve/hosted-harness-session.ts | 17 ++-- .../sdk-java/managed-agent-server/README.md | 4 +- .../managedagent/store/ManagedAgentStore.java | 36 +++++---- ...V48__managed_mutation_attempt_sequence.sql | 2 + .../ManagedSessionLifecycleTest.java | 62 ++++++++++++++- 6 files changed, 174 insertions(+), 24 deletions(-) create mode 100644 packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V48__managed_mutation_attempt_sequence.sql diff --git a/packages/cli/src/serve/hosted-harness-session.test.ts b/packages/cli/src/serve/hosted-harness-session.test.ts index 1a33297cda7..bfd749bbf0c 100644 --- a/packages/cli/src/serve/hosted-harness-session.test.ts +++ b/packages/cli/src/serve/hosted-harness-session.test.ts @@ -8715,6 +8715,83 @@ describe('Hosted Harness Runtime turn takeover', () => { } }, 30_000); + it.each(['resolved', 'rejected'])( + 'keeps teardown fenced until overlapping passive loads finish (%s)', + async (secondOutcome) => { + const { server, clientId } = await parkToolTurn(true); + vi.spyOn(HostedWorkspaceBroker.prototype, 'status').mockResolvedValue({ + state: 'prepared', + }); + const release = vi.mocked(HostedWorkspaceBroker.prototype.release); + release.mockClear(); + let finishFirst!: () => void; + let finishSecond!: () => void; + let rejectSecond!: (cause: Error) => void; + const firstGate = new Promise((resolve) => { + finishFirst = resolve; + }); + const secondGate = new Promise((resolve, reject) => { + finishSecond = resolve; + rejectSecond = reject; + }); + acquireSpy + .mockImplementationOnce(() => firstGate as never) + .mockImplementationOnce(() => secondGate as never); + const passiveLoad = () => + headers(supertest(server).post(`/session/${SESSION_ID}/load`)) + .send({ + managedSessionStore: store(), + toolProfile: FILE_PROFILE, + passiveManagedRuntimeRecovery: true, + }) + .then((response) => response); + let firstLoad: Promise | undefined; + let secondLoad: Promise | undefined; + try { + firstLoad = passiveLoad(); + secondLoad = passiveLoad(); + await vi.waitFor(() => expect(acquireSpy).toHaveBeenCalledTimes(2), { + timeout: 10_000, + }); + finishFirst(); + const first = await firstLoad; + expect(first.status).toBe(200); + expect(first.body.clientId).toBe(clientId); + const prematureClose = await headers( + supertest(server).delete(`/session/${SESSION_ID}`), + ); + expect(prematureClose.status).toBe(409); + expect(prematureClose.body.code).toBe('hosted_turn_active'); + expect(release).not.toHaveBeenCalled(); + if (secondOutcome === 'rejected') { + rejectSecond(new Error('store hiccup')); + } else { + finishSecond(); + } + const second = await secondLoad; + expect(second.status).toBe(secondOutcome === 'resolved' ? 200 : 409); + const closed = await headers( + supertest(server).delete(`/session/${SESSION_ID}`), + ); + expect(closed.status).toBe(204); + expect(release).toHaveBeenCalledOnce(); + expect( + (release.mock.contexts[0] as HostedWorkspaceBroker).runtimeSessionId, + ).toBe(PROMPT_ID); + const repeatedClose = await headers( + supertest(server).delete(`/session/${SESSION_ID}`), + ); + expect(repeatedClose.status).toBe(404); + expect(release).toHaveBeenCalledOnce(); + } finally { + finishFirst(); + finishSecond(); + await Promise.all([firstLoad, secondLoad]); + } + }, + 30_000, + ); + it('refuses a teardown that lands after a resident passive load published its adoption', async () => { const { server } = await parkToolTurn(true); const release = vi.mocked(HostedWorkspaceBroker.prototype.release); diff --git a/packages/cli/src/serve/hosted-harness-session.ts b/packages/cli/src/serve/hosted-harness-session.ts index 5a5270cd41f..699bf2967da 100644 --- a/packages/cli/src/serve/hosted-harness-session.ts +++ b/packages/cli/src/serve/hosted-harness-session.ts @@ -195,7 +195,7 @@ interface HostedSession { hooksBusy?: boolean; mcpBusy?: boolean; mcpClosing?: boolean; - mcpRecovering?: boolean; + mcpRecovering: number; approval?: HostedApprovalSettings; waiters: HostedApprovalWaiters; monitorWake?: HostedMonitorWakeScheduler; @@ -1763,7 +1763,7 @@ export function registerHostedHarnessSessionRoutes( // can still reach, so the recovery holds close()'s own fence: a // concurrent teardown would otherwise release a lease that is still // mid-adoption and persist the record RELEASED. - resident.mcpRecovering = true; + resident.mcpRecovering += 1; const recovered = await recoverHostedRuntimeTurn({ session: resident.managed, sessionId, @@ -1775,7 +1775,7 @@ export function registerHostedHarnessSessionRoutes( resident.runtimeLeaseHeld = runtimeSessionId; }, }).finally(() => { - resident.mcpRecovering = false; + resident.mcpRecovering -= 1; }); recovery = recovered?.report; if ( @@ -1959,6 +1959,7 @@ export function registerHostedHarnessSessionRoutes( streams: new Set(), admissions: new Map(), blocked: false, + mcpRecovering: 0, waiters: new HostedApprovalWaiters(), ...(toolProfile ? { toolProfile } : {}), ...(isHostedWorkspaceShellProfile(toolProfile) && @@ -2042,7 +2043,7 @@ export function registerHostedHarnessSessionRoutes( const wakeBusy = () => session.active !== undefined || session.mcpBusy === true || - session.mcpRecovering === true || + session.mcpRecovering > 0 || session.hooksBusy === true || session.mcpClosing === true; const wakeBlocked = () => @@ -2991,7 +2992,7 @@ export function registerHostedHarnessSessionRoutes( if (!session.mcp) return error(res, 409, 'hosted_mcp_unavailable'); if (session.mcpClosing || session.mcpRecovering) return error(res, 409, 'hosted_mcp_operation_active'); - session.mcpRecovering = true; + session.mcpRecovering += 1; void session.mcp .cancel(req.params['operationId']) .then( @@ -2999,7 +3000,7 @@ export function registerHostedHarnessSessionRoutes( () => error(res, 503, 'hosted_mcp_cancel_failed'), ) .finally(() => { - session.mcpRecovering = false; + session.mcpRecovering -= 1; }); }); @@ -3009,7 +3010,7 @@ export function registerHostedHarnessSessionRoutes( if (!session.mcp) return error(res, 409, 'hosted_mcp_unavailable'); if (session.mcpClosing || session.mcpRecovering) return error(res, 409, 'hosted_mcp_operation_active'); - session.mcpRecovering = true; + session.mcpRecovering += 1; void session.mcp .status(req.params['operationId']) .then( @@ -3017,7 +3018,7 @@ export function registerHostedHarnessSessionRoutes( () => error(res, 503, 'hosted_mcp_status_failed'), ) .finally(() => { - session.mcpRecovering = false; + session.mcpRecovering -= 1; }); }); diff --git a/packages/sdk-java/managed-agent-server/README.md b/packages/sdk-java/managed-agent-server/README.md index 119634c352d..0a302a33b42 100644 --- a/packages/sdk-java/managed-agent-server/README.md +++ b/packages/sdk-java/managed-agent-server/README.md @@ -223,7 +223,9 @@ sibling answers `409 session_mutation_superseded` and the newer public SQL title The Harness title was already written before this check; ordering overlapping Harness writes remains a follow-up tracked in #13269. A same-key request sent after the later rename is the newest request and still -applies. +applies, including when a concurrent sibling retires its receipt again. Each +new attempt records the Session's current journal sequence on its command row; +existing receipts use their original requested event until they are retried. Retries do not re-append the original `requested` event. If the command store is unavailable during cleanup, the original API failure is preserved and the same key can resume its receipt when storage returns. Only an in-flight diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java index 36b1ebe1c77..e2f51b6ccae 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java @@ -556,9 +556,11 @@ public SessionMutationCommand beginSessionMutation(String tenantId, requireNoOpenOperation(tenantId, sessionId); validateMutationStatus(session, kind); jdbc.update("UPDATE managed_agent_command SET command_status =" - + " 'PENDING', updated_at = ? WHERE tenant_id = ?" + + " 'PENDING', mutation_attempt_sequence = ?," + + " updated_at = ? WHERE tenant_id = ?" + " AND operation = ? AND idempotency_key = ?", - clock.millis(), tenantId, operation, idempotencyKey); + session.lastSequence(), clock.millis(), tenantId, + operation, idempotencyKey); return new SessionMutationCommand(sessionId, "PENDING", true); } return new SessionMutationCommand(sessionId, command.status(), @@ -569,6 +571,10 @@ public SessionMutationCommand beginSessionMutation(String tenantId, long now = clock.millis(); insertCommand(tenantId, operation, idempotencyKey, requestDigest, sessionId, null, "PENDING", session.status(), now); + jdbc.update("UPDATE managed_agent_command SET mutation_attempt_sequence" + + " = ? WHERE tenant_id = ? AND operation = ?" + + " AND idempotency_key = ?", + session.lastSequence(), tenantId, operation, idempotencyKey); // Older retired commands may have left their requested event behind. String requestedSource = mutationSource(operation, idempotencyKey, "requested"); @@ -2767,26 +2773,30 @@ private static String mutationEvent(SessionMutationKind kind, + "." + phase; } - // Mutation commands on one Session begin one at a time, so a completion - // sequenced after this command's requested event belongs to a command - // that began after this one stopped being PENDING. Sequence ids order - // that strictly, where millisecond timestamps can tie. + // The Session lock orders each new attempt against committed events. + // Legacy receipts fall back to their original requested event; a + // re-drive refreshes the boundary without appending another event. private boolean supersededByLaterMutation(String tenantId, String sessionId, String operation, String idempotencyKey, SessionMutationKind kind) { - List requested = jdbc.queryForList("SELECT sequence_id FROM" - + " managed_agent_event WHERE tenant_id = ? AND" - + " session_id = ? AND source_key = ?", - Long.class, tenantId, sessionId, - mutationSource(operation, idempotencyKey, "requested")); - if (requested.isEmpty()) { + List attempts = jdbc.queryForList("SELECT COALESCE(" + + " c.mutation_attempt_sequence, e.sequence_id) FROM" + + " managed_agent_command c LEFT JOIN managed_agent_event e" + + " ON e.tenant_id = c.tenant_id AND" + + " e.session_id = c.session_id AND e.source_key = ?" + + " WHERE c.tenant_id = ? AND c.session_id = ? AND" + + " c.operation = ? AND c.idempotency_key = ?", + Long.class, mutationSource(operation, idempotencyKey, + "requested"), tenantId, sessionId, operation, + idempotencyKey); + if (attempts.isEmpty() || attempts.getFirst() == null) { return false; } Integer later = jdbc.queryForObject("SELECT COUNT(*) FROM" + " managed_agent_event WHERE tenant_id = ? AND" + " session_id = ? AND sequence_id > ? AND" + " event_type = ? AND source_key LIKE 'control:%'", - Integer.class, tenantId, sessionId, requested.getFirst(), + Integer.class, tenantId, sessionId, attempts.getFirst(), mutationEvent(kind, "completed")); return later != null && later > 0; } diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V48__managed_mutation_attempt_sequence.sql b/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V48__managed_mutation_attempt_sequence.sql new file mode 100644 index 00000000000..ad3a0bf5ce6 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V48__managed_mutation_attempt_sequence.sql @@ -0,0 +1,2 @@ +-- Existing receipts retain their original requested-event boundary until retried. +ALTER TABLE managed_agent_command ADD COLUMN mutation_attempt_sequence BIGINT; diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java index 687836f6b8b..b1681bc80fe 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java @@ -498,8 +498,10 @@ void failedRenameReceiptKeepsItsDigestAndConcurrentCompletion() throws Exception Integer.class, tenant, sessionId)).isEqualTo(1); } - @Test - void retiredRenameCannotCompleteOverALaterCompletedRename() throws Exception { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void retiredRenameCannotCompleteOverALaterCompletedRename(boolean legacyReceipt) + throws Exception { String tenant = tenant(); String sessionId = attachedSession(tenant); String bootId = store.requireSession(tenant, sessionId).harnessBootId(); @@ -515,6 +517,12 @@ void retiredRenameCannotCompleteOverALaterCompletedRename() throws Exception { store.beginSessionMutation(tenant, "RENAME_SESSION", "k1", first, sessionId, SessionMutationKind.RENAME); store.abandonSessionMutation(tenant, "RENAME_SESSION", "k1", sessionId); + if (legacyReceipt) { + jdbc.update("UPDATE managed_agent_command SET mutation_attempt_sequence" + + " = NULL WHERE tenant_id = ? AND operation = ?" + + " AND idempotency_key = ?", + tenant, "RENAME_SESSION", "k1"); + } store.beginSessionMutation(tenant, "RENAME_SESSION", "k2", second, sessionId, SessionMutationKind.RENAME); store.completeSessionMutation(tenant, "RENAME_SESSION", "k2", sessionId, @@ -547,6 +555,56 @@ void retiredRenameCannotCompleteOverALaterCompletedRename() throws Exception { .andExpect(jsonPath("$.metadata.title").value("A")); } + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void latestRenameAttemptCompletesAfterItsSiblingRetires(boolean recreatedReceipt) + throws Exception { + String tenant = tenant(); + String sessionId = attachedSession(tenant); + String bootId = store.requireSession(tenant, sessionId).harnessBootId(); + RequestDigests digests = new RequestDigests(); + String first = digests.digest( + java.util.Map.of("sessionId", sessionId, "title", "A")); + String second = digests.digest( + java.util.Map.of("sessionId", sessionId, "title", "B")); + store.beginSessionMutation(tenant, "RENAME_SESSION", "k1", first, + sessionId, SessionMutationKind.RENAME); + store.abandonSessionMutation(tenant, "RENAME_SESSION", "k1", sessionId); + store.beginSessionMutation(tenant, "RENAME_SESSION", "k2", second, + sessionId, SessionMutationKind.RENAME); + harness.rename(tenant, sessionId, "B"); + store.completeSessionMutation(tenant, "RENAME_SESSION", "k2", sessionId, + SessionMutationKind.RENAME, "B", bootId); + if (recreatedReceipt) { + jdbc.update("DELETE FROM managed_agent_command WHERE tenant_id = ?" + + " AND operation = ? AND idempotency_key = ?", + tenant, "RENAME_SESSION", "k1"); + } + store.beginSessionMutation(tenant, "RENAME_SESSION", "k1", first, + sessionId, SessionMutationKind.RENAME); + store.beginSessionMutation(tenant, "RENAME_SESSION", "k1", first, + sessionId, SessionMutationKind.RENAME); + store.abandonSessionMutation(tenant, "RENAME_SESSION", "k1", sessionId); + harness.rename(tenant, sessionId, "A"); + assertThat(store.completeSessionMutation(tenant, "RENAME_SESSION", "k1", + sessionId, SessionMutationKind.RENAME, "A", bootId).title()) + .isEqualTo("A"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_event" + + " WHERE tenant_id = ? AND session_id = ? AND source_key = ?", + Integer.class, tenant, sessionId, + "control:RENAME_SESSION:k1:requested")).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_event" + + " WHERE tenant_id = ? AND session_id = ?" + + " AND event_type = 'session.updated'", + Integer.class, tenant, sessionId)).isEqualTo(2); + lifecycle(patch("/v1/agents/sessions/{id}", sessionId) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"A\"}"), tenant, "k1") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.metadata.title").value("A")) + .andExpect(header().string("X-Qwen-Idempotent-Replay", "true")); + } + @Test void retryingFailedRenameAgainBlocksOtherLifecycleWork() throws Exception { String tenant = tenant(); From 6e6da3cbdb2e4509f50ed6cfeeb24e2d6290cdf1 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 7 Oct 2026 00:23:47 +0800 Subject: [PATCH 60/73] fix(test): import Mockito times in coordinator tests Co-authored-by: Qwen-Coder --- .../qwen/code/managedagent/service/HarnessCoordinatorTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java index 6cfd33bf91b..751cc4350d7 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java @@ -13,6 +13,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions; From f2864f6a1d06f0cfa0b69891695fa27a70b814bb Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 7 Oct 2026 13:55:36 +0800 Subject: [PATCH 61/73] fix(serve): preserve live cancellation reattachment Skip ownerless cancellation settlement while the original Turn still runs, so a cold Java attachment can reattach passively and cancel that owner. Co-authored-by: Qwen-Coder --- .../cli/src/serve/hosted-harness-session.test.ts | 14 ++++++++++++-- packages/cli/src/serve/hosted-harness-session.ts | 1 + 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/serve/hosted-harness-session.test.ts b/packages/cli/src/serve/hosted-harness-session.test.ts index 16a98fcef5a..156e8599ecd 100644 --- a/packages/cli/src/serve/hosted-harness-session.test.ts +++ b/packages/cli/src/serve/hosted-harness-session.test.ts @@ -6874,7 +6874,7 @@ describe('Hosted Harness no-tool session', () => { await headers(supertest(server).delete(`/session/${SESSION_ID}`)); }); - it('reports an aborted turn as cancelled to the Java event projector', async () => { + it('reports an aborted live turn as cancelled after a Java-style cold reattach', async () => { const log = vi .spyOn(stdio, 'writeStderrLineSafe') .mockImplementation(() => {}); @@ -6902,9 +6902,19 @@ describe('Hosted Harness no-tool session', () => { .send({ prompt, promptId: PROMPT_ID, payloadDigest }); expect(admitted.status).toBe(202); await vi.waitFor(() => expect(state.model).toHaveBeenCalledTimes(1)); + const reattached = await headers( + supertest(server).post(`/session/${SESSION_ID}/load`), + ).send({ + managedSessionStore: store(), + passiveManagedRuntimeRecovery: true, + driveRuntimeRecovery: false, + cancellationTakeover: true, + }); + expect(reattached.status).toBe(200); + expect(reattached.body.clientId).toBe(created.body.clientId); const cancelled = await headers( supertest(server).post(`/session/${SESSION_ID}/cancel`), - ).set('X-Qwen-Client-Id', created.body.clientId as string); + ).set('X-Qwen-Client-Id', reattached.body.clientId as string); expect(cancelled.status).toBe(204); await vi.waitFor( async () => { diff --git a/packages/cli/src/serve/hosted-harness-session.ts b/packages/cli/src/serve/hosted-harness-session.ts index 1274ff6f5f1..23ce179cf05 100644 --- a/packages/cli/src/serve/hosted-harness-session.ts +++ b/packages/cli/src/serve/hosted-harness-session.ts @@ -1898,6 +1898,7 @@ export function registerHostedHarnessSessionRoutes( // exactly as on the first load. if ( !resident.hooks && + resident.active === undefined && (passiveRecovery || driveRecovery) && body?.['cancellationTakeover'] === true ) { From 0227b2501474358be35a13d96c3a2cb6ae5426c1 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 7 Oct 2026 21:35:16 +0800 Subject: [PATCH 62/73] fix(serve): restore resident recovery outcomes Preserve terminal decline reasons and reattach requested approvals. Settle payable terminal projections without retrying settled file history, and refuse a cancellation attachment removed during settlement. Co-authored-by: Qwen-Coder --- .../src/serve/hosted-harness-session.test.ts | 178 +++++++++++++++++- .../cli/src/serve/hosted-harness-session.ts | 152 +++++++++------ 2 files changed, 276 insertions(+), 54 deletions(-) diff --git a/packages/cli/src/serve/hosted-harness-session.test.ts b/packages/cli/src/serve/hosted-harness-session.test.ts index 156e8599ecd..49a55b9d0f6 100644 --- a/packages/cli/src/serve/hosted-harness-session.test.ts +++ b/packages/cli/src/serve/hosted-harness-session.test.ts @@ -8987,7 +8987,14 @@ describe('Hosted Harness Runtime turn takeover', () => { expect(refused.status).toBe(409); expect(refused.body.code).toBe('hosted_turn_recovery_required'); expect(acquireSpy).toHaveBeenCalledOnce(); - expect(authorization).toHaveBeenCalledTimes(2); + expect(authorization).toHaveBeenCalledTimes( + failure === 'blocked' ? 4 : 2, + ); + if (failure === 'blocked') + expect(await authorization.mock.results[1].value).toMatchObject({ + status: 'blocked', + reason: 'missing_state', + }); expect(release).not.toHaveBeenCalled(); const firstRefusalDiagnostics = owedLines().length; let repeatedRefusalDiagnostics: number | undefined; @@ -9616,6 +9623,175 @@ describe('Hosted Harness Runtime turn takeover', () => { expect(loaded.body.reason).toBe('model_start'); }); + it('carries a resident kernel decline reason onto the wire', async () => { + const { server } = await parkToolTurn(true); + vi.spyOn( + LocalManagedSessionAuthority.prototype, + 'harnessRunAuthorization', + ).mockResolvedValue({ status: 'blocked', reason: 'identity_mismatch' }); + const declined = await headers( + supertest(server).post(`/session/${SESSION_ID}/load`), + ).send({ managedSessionStore: store(), driveRuntimeRecovery: true }); + expect(declined.status).toBe(409); + expect(declined.body.code).toBe('hosted_turn_recovery_declined'); + expect(declined.body.reason).toBe('checkpoint_blocked'); + }); + + it.each([false, true])( + 'reattaches a resident requested approval (passive=%s)', + async (passive) => { + const { server, clientId } = await parkToolTurn(true); + mockAuthorizationWithPhase('await_approval', { state: 'requested' }); + const loaded = await headers( + supertest(server).post(`/session/${SESSION_ID}/load`), + ).send({ + managedSessionStore: store(), + toolProfile: FILE_PROFILE, + [passive ? 'passiveManagedRuntimeRecovery' : 'driveRuntimeRecovery']: + true, + }); + expect(loaded.status).toBe(200); + expect(loaded.body.clientId).toBe(clientId); + expect(loaded.body.recoveryRequired).toBeUndefined(); + expect(acquireSpy).not.toHaveBeenCalled(); + }, + ); + + it.each([false, true])( + 'settles a payable resident projection after a lost terminal write (passive=%s)', + async (passive) => { + await parkToolTurn(); + vi.mocked(HostedWorkspaceBroker.prototype.execute).mockResolvedValue({ + executionStatus: 'success', + responseParts: [{ text: 'written' }], + } as never); + const { server, loaded } = await loadReplacement(); + expect(loaded.status).toBe(200); + const recovery = loaded.body._meta['qwen.daemon.managedRuntimeRecovery']; + const originalWrite = ManagedSessionRecordSink.prototype.write; + const write = vi + .spyOn(ManagedSessionRecordSink.prototype, 'write') + .mockImplementation(function (this: ManagedSessionRecordSink, item) { + if (item.subtype === 'turn_result') + return Promise.reject(new Error('terminal write unavailable')); + return originalWrite.call(this, item); + }); + await replacementHeaders( + supertest(server).post( + `/session/${SESSION_ID}/managed-runtime/continue`, + ), + ) + .set('X-Qwen-Client-Id', loaded.body.clientId) + .send({ + promptId: PROMPT_ID, + checkpointId: recovery.checkpointId, + activationId: recovery.activationId, + }) + .expect(200); + await vi.waitFor(async () => { + const status = await replacementHeaders( + supertest(server).get(`/session/${SESSION_ID}/status`), + ).set('X-Qwen-Client-Id', loaded.body.clientId); + expect(status.body.hasActivePrompt).toBe(false); + expect(status.body.recoveryBlocked).toBe(true); + }); + write.mockRestore(); + const history = await replacementHeaders( + supertest(server).get(`/session/${SESSION_ID}/files/history`), + ).set('X-Qwen-Client-Id', loaded.body.clientId); + expect(history.body.history.pendingTurn).toBeNull(); + expect(history.body.history.pendingUndo).toBeNull(); + mockAuthorizationWithPhase('turn_settled', null); + const redriven = await replacementHeaders( + supertest(server).post(`/session/${SESSION_ID}/load`), + ).send({ + managedSessionStore: storeFor(BOOT_ID_2), + toolProfile: FILE_PROFILE, + [passive ? 'passiveManagedRuntimeRecovery' : 'driveRuntimeRecovery']: + true, + }); + expect(redriven.status).toBe(200); + expect(redriven.body.clientId).toBe(loaded.body.clientId); + await vi.waitFor(async () => { + const transcript = await replacementHeaders( + supertest(server).get(`/session/${SESSION_ID}/transcript`), + ).set('X-Qwen-Client-Id', loaded.body.clientId); + expect(transcript.body.events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'turn_complete', + promptId: PROMPT_ID, + }), + ]), + ); + }); + }, + ); + + it('refuses a cancellation takeover attachment deleted during settlement', async () => { + const { server } = await parkToolTurn(true); + const authorize = + LocalManagedSessionAuthority.prototype.harnessRunAuthorization; + vi.spyOn( + LocalManagedSessionAuthority.prototype, + 'harnessRunAuthorization', + ).mockImplementation(async function (this: LocalManagedSessionAuthority) { + const result = await authorize.call(this); + if (result.status !== 'runnable') return result; + return { + ...result, + checkpoint: { + ...result.checkpoint, + approval: null, + tools: { + ...result.checkpoint.tools, + items: result.checkpoint.tools!.items.map((item) => ({ + ...item, + state: 'settled', + consumed: true, + })), + }, + }, + } as never; + }); + let started!: () => void; + const settling = new Promise((resolve) => { + started = resolve; + }); + let resume!: () => void; + const held = new Promise((resolve) => { + resume = resolve; + }); + const originalWrite = ManagedSessionRecordSink.prototype.write; + vi.spyOn(ManagedSessionRecordSink.prototype, 'write').mockImplementation( + async function (this: ManagedSessionRecordSink, item) { + await originalWrite.call(this, item); + if (item.subtype === 'turn_result') { + started(); + await held; + } + }, + ); + const load = headers(supertest(server).post(`/session/${SESSION_ID}/load`)) + .send({ + managedSessionStore: store(), + passiveManagedRuntimeRecovery: true, + cancellationTakeover: true, + }) + .then((response) => response); + await settling; + try { + await headers(supertest(server).delete(`/session/${SESSION_ID}`)).expect( + 204, + ); + } finally { + resume(); + } + const refused = await load; + expect(refused.status).toBe(404); + expect(refused.body.code).toBe('hosted_session_not_found'); + }); + it('re-answers the unchanged recovery after a lost cancellation report', async () => { // Nothing consumed means nothing to unwedge: a failed cancel cannot // break the re-answer — the redrive recomputes from the attached state diff --git a/packages/cli/src/serve/hosted-harness-session.ts b/packages/cli/src/serve/hosted-harness-session.ts index 23ce179cf05..3cc3432875f 100644 --- a/packages/cli/src/serve/hosted-harness-session.ts +++ b/packages/cli/src/serve/hosted-harness-session.ts @@ -825,6 +825,7 @@ async function settleProjectablePromptId( return null; if ( fileHistory && + (fileHistory.pendingTurn || fileHistory.pendingUndo) && !(await canSettleHostedFileHistory(managed, { ...fileHistory, pendingTurn: promptId, @@ -1773,6 +1774,50 @@ export function registerHostedHarnessSessionRoutes( }); }; + const answerResidentInapplicable = async ( + res: Response, + sessionId: string, + resident: HostedSession, + promptId: string, + ): Promise => { + const authorization = + await resident.managed.authority.harnessRunAuthorization(); + const approvalPending = + authorization.status === 'runnable' && + authorization.checkpoint.approval?.state === 'requested'; + const settle = approvalPending + ? null + : await settleProjectablePromptId( + resident.managed, + resident, + await readHostedFileHistory(resident.managed), + promptId, + ); + if (!approvalPending && settle === null) { + noteOwedAdoption(resident, sessionId); + writeStderrLineSafe( + `qwen serve: Hosted Session ${sessionId} redrive refused (takeover_inapplicable_unpayable): prompt=${promptId}`, + ); + error(res, 409, 'hosted_turn_recovery_required'); + return; + } + if (sessions.get(sessionId) !== resident) { + noteOwedAdoption(resident, sessionId); + error(res, 404, 'hosted_session_not_found'); + return; + } + if (resident.mcpClosing) { + noteOwedAdoption(resident, sessionId); + error(res, 409, 'hosted_session_closing'); + return; + } + refusedAdoptions.delete(sessionId); + resident.blocked = false; + sendAttachment(res, sessionId, resident); + if (settle !== null) + runSettleProjection(resident, sessionId, settle, brokerOptions); + }; + const open = async ( req: Request, res: Response, @@ -1920,6 +1965,14 @@ export function registerHostedHarnessSessionRoutes( sessionId, parkedForCancellation, ); + if (sessions.get(sessionId) !== resident) { + error(res, 404, 'hosted_session_not_found'); + return; + } + if (resident.mcpClosing) { + error(res, 409, 'hosted_session_closing'); + return; + } writeStderrLineSafe( `qwen serve: Hosted Session ${sessionId} settles the cancelled park on the redriven load: prompt=${parkedForCancellation}`, ); @@ -1988,18 +2041,11 @@ export function registerHostedHarnessSessionRoutes( leaseAlreadyHeld: resident.runtimeLeaseHeld !== undefined, }); if (outcome.kind === 'declined') { - error(res, 409, 'hosted_session_already_attached'); + recoveryDeclined(res, outcome.reason); return; } if (outcome.kind === 'inapplicable') { - // An inapplicable drive redrive owes this Session nothing: the - // kernel released whatever it took, so the refusal carries no - // owed lease — it is named instead of released, because a release - // would persist RELEASED and wedge every retried acquire. - writeStderrLineSafe( - `qwen serve: Hosted Session ${sessionId} redrive refused (takeover_inapplicable_unpayable): prompt=${parked}`, - ); - error(res, 409, 'hosted_turn_recovery_required'); + await answerResidentInapplicable(res, sessionId, resident, parked); return; } recovery = outcome.turn.report; @@ -2084,51 +2130,51 @@ export function registerHostedHarnessSessionRoutes( // concurrent teardown would otherwise release a lease that is still // mid-adoption and persist the record RELEASED. resident.mcpRecovering += 1; - const outcome = await recoverHostedRuntimeTurn({ - session: resident.managed, - sessionId, - cwd: resident.cwd, - promptId: parked, - brokerOptions, - passive: true, - onPassiveRuntimeAcquired: (runtimeSessionId) => { - resident.runtimeLeaseHeld = runtimeSessionId; - }, - }).finally(() => { + try { + const outcome = await recoverHostedRuntimeTurn({ + session: resident.managed, + sessionId, + cwd: resident.cwd, + promptId: parked, + brokerOptions, + passive: true, + onPassiveRuntimeAcquired: (runtimeSessionId) => { + resident.runtimeLeaseHeld = runtimeSessionId; + }, + }); + if (outcome.kind === 'recovered') { + recovery = outcome.turn.report; + } else if (outcome.kind === 'inapplicable') { + await answerResidentInapplicable( + res, + sessionId, + resident, + parked, + ); + return; + } else { + // A declined passive load was refused before any adoption, so + // nothing is owed; the typed code tells the coordinator the + // refusal is terminal. + recoveryDeclined(res, outcome.reason); + return; + } + if ( + !resident.active && + unsettledPromptId(resident) && + (!recovery || + parked !== unsettledPromptId(resident) || + recovery.checkpointId !== + resident.managed.authority.latestCheckpoint?.checkpointId || + recovery.activationId !== + resident.managed.activation.activationId) + ) { + noteOwedAdoption(resident, sessionId); + error(res, 409, 'hosted_turn_recovery_required'); + return; + } + } finally { resident.mcpRecovering -= 1; - }); - if (outcome.kind === 'recovered') { - recovery = outcome.turn.report; - } else if (outcome.kind === 'inapplicable') { - // An inapplicable passive recovery took the adoption but cannot - // settle it here, so the lease stays owed for the teardown's - // release while the coordinator retries the refusal. - noteOwedAdoption(resident, sessionId); - writeStderrLineSafe( - `qwen serve: Hosted Session ${sessionId} redrive refused (takeover_inapplicable_unpayable): prompt=${parked}`, - ); - error(res, 409, 'hosted_turn_recovery_required'); - return; - } else { - // A declined passive load was refused before any adoption, so - // nothing is owed; the typed code tells the coordinator the - // refusal is terminal. - recoveryDeclined(res, outcome.reason); - return; - } - if ( - !resident.active && - unsettledPromptId(resident) && - (!recovery || - parked !== unsettledPromptId(resident) || - recovery.checkpointId !== - resident.managed.authority.latestCheckpoint?.checkpointId || - recovery.activationId !== - resident.managed.activation.activationId) - ) { - noteOwedAdoption(resident, sessionId); - error(res, 409, 'hosted_turn_recovery_required'); - return; } } // The fence above covers the whole adoption, so these exits answer From a9f7fec138ee1c132f9440bee2904cee1acabb47 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 7 Oct 2026 21:35:18 +0800 Subject: [PATCH 63/73] fix(managed-agent): retry transient approval mount failures Use delivery-time mount verification for action responses and recover cold approval attachments passively after rechecking new-work authority. Preserve shared acquire verdicts and permanent workspace refusals. Co-authored-by: Qwen-Coder --- .../harness/QwenHostedHarnessConnector.java | 17 +++-- .../QwenHostedHarnessConnectorTest.java | 10 +-- .../store/WorkspaceStorageGuardTest.java | 67 +++++++++++++++++++ 3 files changed, 84 insertions(+), 10 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java index 34bf8e53689..9a0973aa5e5 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java @@ -229,7 +229,7 @@ public Admission submit(String tenantId, String sessionId, private Admission doSubmit(String tenantId, String sessionId, String promptId, List> input, String payloadDigest) { - requireReadyForNewWork(tenantId, sessionId); + requireReadyForNewWork(tenantId, sessionId, false); SubmitHarnessTurn.Builder builder = SubmitHarnessTurn.builder() .session(attachment(tenantId, sessionId, true)) .promptId(promptId) @@ -257,7 +257,7 @@ public Admission continueManagedRuntime(String tenantId, private Admission doContinueManagedRuntime(String tenantId, String sessionId, String promptId, String checkpointId, String activationId) { - requireReadyForNewWork(tenantId, sessionId); + requireReadyForNewWork(tenantId, sessionId, false); // Resolve the attachment BEFORE fetching the client: the resolution // may block on a create/load round trip, and an adoption closing the // captured client during that window would strand this call on a @@ -355,8 +355,8 @@ private void doResolveAction( String sessionId, String actionId, JsonNode response) { - requireReadyForNewWork(tenantId, sessionId); - HarnessSessionRef ref = attachment(tenantId, sessionId, true); + requireReadyForNewWork(tenantId, sessionId, true); + HarnessSessionRef ref = attachment(tenantId, sessionId, false); client().resolveAction( ref, actionId, @@ -446,13 +446,18 @@ private HarnessSessionRef cancellationAttachment(String tenantId, String session return attached; } - private void requireReadyForNewWork(String tenantId, String sessionId) { + private void requireReadyForNewWork(String tenantId, String sessionId, boolean actionResponse) { SessionRecord session = sessions.requireSession(tenantId, sessionId); if (session.workspace() != null) { if (!isWorkspaceFilesAvailable()) { throw new IllegalStateException("Hosted Workspace files are disabled"); } - workspaceExecution.authorize(session); + if (actionResponse) { + workspaceExecution.authorizePassiveAttachment(session); + workspaceExecution.verifyMountForProbe(session.workspace()); + } else { + workspaceExecution.authorize(session); + } } } diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java index 32f2dd796ac..d55ed6bbef7 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java @@ -424,7 +424,8 @@ void resolvesActionsThroughAuthorizedColdAndCachedWorkspaceAttachments() { var response = new ObjectMapper().createObjectNode().put("optionId", "allow") .put("inputRevision", 7L).put("policyRevision", "hosted-tool-approval/1"); - doThrow(WorkspaceExecutionStore.unavailable()).doNothing().when(execution).authorize(session); + doThrow(WorkspaceExecutionStore.unavailable()).doNothing() + .when(execution).authorizePassiveAttachment(session); assertThatThrownBy(() -> connector.resolveAction("tenant-a", SESSION_ID, actionId, response)) .hasMessageContaining("Workspace execution authority is unavailable"); verify(client, never()).loadSession(any()); @@ -441,15 +442,16 @@ void resolvesActionsThroughAuthorizedColdAndCachedWorkspaceAttachments() { for (LoadHarnessSession load : loads.getAllValues()) { Map wire = ReflectionTestUtils.invokeMethod(load, "toJson"); assertThat(wire).containsEntry("toolProfile", "hosted-workspace-files/1") - .doesNotContainKey("passiveManagedRuntimeRecovery"); + .containsEntry("passiveManagedRuntimeRecovery", true); assertThat(wire.get("managedSessionStore").toString()) .contains("tenantId=tenant-a", "workspaceId=selected-workspace") .doesNotContain("workspaceId=workspace-a"); } - verify(execution, never()).authorizePassiveAttachment(any()); + verify(execution, never()).authorize(any()); + verify(execution, times(2)).verifyMountForProbe(session.workspace()); verify(client, never()).createSession(any()); - doThrow(WorkspaceExecutionStore.unavailable()).when(execution).authorize(session); + doThrow(WorkspaceExecutionStore.unavailable()).when(execution).authorizePassiveAttachment(session); assertThatThrownBy(() -> connector.resolveAction("tenant-a", SESSION_ID, actionId, response)) .hasMessageContaining("Workspace execution authority is unavailable"); verify(client, times(1)).resolveAction(any(), any(), any(), anyLong(), any()); diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceStorageGuardTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceStorageGuardTest.java index cbf628fff79..264929c5a6c 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceStorageGuardTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceStorageGuardTest.java @@ -2,11 +2,25 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.alibaba.qwen.code.daemon.HarnessSessionRef; +import com.alibaba.qwen.code.daemon.HostedHarnessCapabilities; +import com.alibaba.qwen.code.daemon.HostedHarnessClient; import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties.RuntimeBroker.WorkspaceMount; +import com.alibaba.qwen.code.managedagent.harness.QwenHostedHarnessConnector; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionRecord; import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding; +import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -27,6 +41,7 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.support.DefaultTransactionStatus; @@ -127,6 +142,58 @@ void reportsUnreadableIdentityWithoutClaimingMismatch() { assertUnavailable(() -> unavailable.verify(binding)); } + @Test + void actionResponseRetriesAMomentaryMountReadWithoutChangingAcquireVerdicts() { + AtomicInteger unreadable = new AtomicInteger(); + var manager = new DataSourceTransactionManager(dataSource); + var guard = new WorkspaceStorageGuard(jdbc, manager, properties, path -> { + if (unreadable.get() != 0) { + throw new IOException("momentary mount failure"); + } + return new WorkspaceStorageGuard.Identity(path.toString(), "host", "device", "inode", + "2026-10-07T00:00:00Z"); + }); + guard.register("tenant", "storage", UUID.randomUUID().toString()); + String sessionId = UUID.randomUUID().toString(); + var session = new SessionRecord("tenant", sessionId, "qwen-code", null, + null, "ACTIVE", null, null, 0, 0, 0, 1, 1, null, 1, + binding, "default", "hosted-workspace-files/1"); + var execution = spy(new WorkspaceExecutionStore(jdbc, manager, guard)); + doNothing().when(execution).authorizePassiveAttachment(session); + var sessions = mock(AgentStateStore.class); + when(sessions.requireSession("tenant", sessionId)).thenReturn(session); + var actions = mock(ManagedActionStore.class); + when(actions.approvalMode("tenant", sessionId)).thenReturn("default"); + properties.getHarness().setToken("test-token"); + properties.getHarness().setCapabilityDigest("sha256:" + "a".repeat(64)); + properties.getHarness().setWorkspaceFilesEnabled(true); + var connector = new QwenHostedHarnessConnector(properties, sessions, execution, actions); + var client = mock(HostedHarnessClient.class); + var capabilities = mock(HostedHarnessCapabilities.class); + when(client.capabilities()).thenReturn(capabilities); + when(capabilities.getBootId()).thenReturn(UUID.randomUUID().toString()); + var attached = mock(HarnessSessionRef.class); + when(attached.getApprovalMode()).thenReturn("default"); + when(client.loadSession(any())).thenReturn(attached); + ReflectionTestUtils.setField(connector, "client", client); + var response = new ObjectMapper().createObjectNode().put("optionId", "allow") + .put("inputRevision", 1).put("policyRevision", "policy"); + unreadable.set(1); + assertThatThrownBy(() -> connector.resolveAction("tenant", sessionId, "action", response)) + .isInstanceOfSatisfying(RuntimeBrokerException.class, error -> { + assertThat(error.getCode()).isEqualTo("workspace_unavailable"); + assertThat(error.isRetryable()).isTrue(); + assertThat(error.getCause()).isInstanceOf(IOException.class); + }); + verify(client, never()).resolveAction(any(), any(), any(), anyLong(), any()); + assertThatThrownBy(() -> guard.verify(binding)) + .isInstanceOfSatisfying(RuntimeBrokerException.class, + error -> assertThat(error.isRetryable()).isFalse()); + unreadable.set(0); + connector.resolveAction("tenant", sessionId, "action", response); + verify(client).resolveAction(attached, "action", "allow", 1L, "policy"); + } + @Test void refusesDirectoryAndSymlinkMarkersWithoutChangingRegistration() throws Exception { String operation = UUID.randomUUID().toString(); From 89aa9d43b87617f513bbe0bf8f868d2774cdea30 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Wed, 7 Oct 2026 23:16:02 +0800 Subject: [PATCH 64/73] fix(managed-agent): renumber the mutation-attempt migration to V51 main published V48__workspace_storage_migration.sql plus V49/V50 while this branch was open, and the merge that pulled them in left two files claiming version 48, so scripts/check-flyway-migrations.js fails the "Flyway migration version uniqueness" gate. V51 is the next free version across both migration locations (SQL and BaseJavaMigration, whose highest is V29). Nothing refers to the file by name or version: the code and tests only use the mutation_attempt_sequence column, and Flyway applies every migration in the location, so the renumber only moves this ALTER after main's V48-V50. Verified locally with the same command the gate runs: node scripts/check-flyway-migrations.js packages/sdk-java/managed-agent-server \ packages/sdk-java/runtime-broker packages/sdk-java/qwencode before: "2 migrations claim version 48" (rc=1); after: "51 migrations, all versions unique" (rc=0). Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-conflict/jmuy7yk8zcm --- ...pt_sequence.sql => V51__managed_mutation_attempt_sequence.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/sdk-java/managed-agent-server/src/main/resources/db/migration/{V48__managed_mutation_attempt_sequence.sql => V51__managed_mutation_attempt_sequence.sql} (100%) diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V48__managed_mutation_attempt_sequence.sql b/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V51__managed_mutation_attempt_sequence.sql similarity index 100% rename from packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V48__managed_mutation_attempt_sequence.sql rename to packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V51__managed_mutation_attempt_sequence.sql From 3c42bc684b5ac276c2f4a35ecee7eaf246dbfe94 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 8 Oct 2026 00:11:49 +0800 Subject: [PATCH 65/73] test(managed-agent): expect the V51 mutation-attempt migration in the upgrade IT WorkspaceMigrationMySqlIT.upgradesCurrentMainWithoutChangingAppliedMigrations pins every migration applied after the V47 baseline, so renumbering V48__managed_mutation_attempt_sequence.sql to V51 left that expectation one version short and the MariaDB failsafe job failed 1 of 58 tests. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-conflict/jmuya3q1ncp --- .../qwen/code/managedagent/store/WorkspaceMigrationMySqlIT.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceMigrationMySqlIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceMigrationMySqlIT.java index 0dd22088b9f..32a4c67dd4a 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceMigrationMySqlIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceMigrationMySqlIT.java @@ -62,7 +62,7 @@ void upgradesCurrentMainWithoutChangingAppliedMigrations() { + " WHERE installed_rank <= ? ORDER BY installed_rank", lastRank)).isEqualTo(applied); assertThat(jdbc.queryForList("SELECT version FROM flyway_schema_history" + " WHERE installed_rank > ? AND success = TRUE ORDER BY installed_rank", - String.class, lastRank)).containsExactly("48", "49", "50"); + String.class, lastRank)).containsExactly("48", "49", "50", "51"); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_workspace_migration", Integer.class)).isZero(); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM qwen_runtime_storage_fence", Integer.class)).isZero(); } From ce56f4395766f8c5bbd8e2aff070a3e225b6a31e Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 8 Oct 2026 01:23:21 +0800 Subject: [PATCH 66/73] test(managed-agent): keep Hosted fixture ports distinct Co-authored-by: Qwen-Coder --- .../managedagent/HostedPublicWorkspaceIT.java | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java index 136758c493a..04ca54e94ab 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java @@ -440,12 +440,18 @@ private List boot() throws Exception { List roots = List.of(Files.createDirectory(temporary.resolve("workspace-a")), Files.createDirectory(temporary.resolve("workspace-b"))); for (Path root : roots) Files.createDirectory(root.resolve("child")); - port = freePort(); - int harnessPort = freePort(); - int brokerPort = freePort(); model = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); model.createContext("/v1/chat/completions", this::modelReply); model.start(); + int harnessPort; + int brokerPort; + try (ServerSocket springSocket = new ServerSocket(0); + ServerSocket harnessSocket = new ServerSocket(0); + ServerSocket brokerSocket = new ServerSocket(0)) { + port = springSocket.getLocalPort(); + harnessPort = harnessSocket.getLocalPort(); + brokerPort = brokerSocket.getLocalPort(); + } startSpring(cli, roots, harnessPort, brokerPort); startHarness(cli, harnessPort, brokerPort); return roots; @@ -845,9 +851,13 @@ private void startHarness(Path cli, int harnessPort, int brokerPort) throws Exce if (!harness.isAlive()) { throw new AssertionError("Hosted Harness exited: " + Files.readString(log)); } - assertThat(http.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + harnessPort + "/capabilities")) + HttpResponse response = 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); + HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()) + .withFailMessage("Hosted Harness port %s returned %s: %s%n%s", harnessPort, + response.statusCode(), response.body(), Files.readString(log)) + .isEqualTo(200); }); } @@ -931,10 +941,6 @@ private static void assertUnavailable(JsonNode refusal) { assertThat(refusal.path("error").path("code").asText()).isEqualTo("workspace_unavailable"); } - private static int freePort() throws IOException { - try (ServerSocket socket = new ServerSocket(0)) { return socket.getLocalPort(); } - } - @AfterEach void stop() throws Exception { if (harness != null) { From 234037eb135680f7685b5c96e33f520960af0ba2 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 8 Oct 2026 10:57:00 +0800 Subject: [PATCH 67/73] fix(managed-agent): retry operator-reversible refusals on the action-response path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A revoked creation grant or a registry that left ACTIVE is operator-mutable state, not the structural verdict the terminal exit promises: stamping the committed approval decision FAILED over it certifies a confirmation the Harness never saw, and restoring the grant cannot recover it. The passive attachment authority now splits its verdict into structural, grant-only, and authorized; only the action-response delivery maps the grant-only refusal to a retryable workspace_unavailable so the delivery machine re-arms while recovery waits for the operator. Every other caller — creation, passive load, cancellation — keeps the previous terminal refusal. --- .../harness/QwenHostedHarnessConnector.java | 2 +- .../store/WorkspaceExecutionStore.java | 106 ++++++++++++------ .../QwenHostedHarnessConnectorTest.java | 4 +- .../store/WorkspaceStorageGuardTest.java | 91 +++++++++++++++ 4 files changed, 165 insertions(+), 38 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java index 9a0973aa5e5..e079ecf283d 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java @@ -453,7 +453,7 @@ private void requireReadyForNewWork(String tenantId, String sessionId, boolean a throw new IllegalStateException("Hosted Workspace files are disabled"); } if (actionResponse) { - workspaceExecution.authorizePassiveAttachment(session); + workspaceExecution.authorizeActionResponse(session); workspaceExecution.verifyMountForProbe(session.workspace()); } else { workspaceExecution.authorize(session); diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceExecutionStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceExecutionStore.java index 8f79cb2ab1b..35ee10bd94c 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceExecutionStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceExecutionStore.java @@ -62,14 +62,22 @@ public void verifyMountForProbe(ContextBinding binding) { } public void authorizePassiveAttachment(SessionRecord session) { - authorizeAttachment(session, false); + authorizeAttachment(session, false, false); } public void authorizeCancellation(SessionRecord session) { - authorizeAttachment(session, true); + authorizeAttachment(session, true, false); } - private void authorizeAttachment(SessionRecord session, boolean cancellation) { + // Action-response delivery only: a refusal stemming solely from + // operator-mutable grants or registry state must not certify the + // terminal verdict, so it answers with the retryable variant instead. + public void authorizeActionResponse(SessionRecord session) { + authorizeAttachment(session, false, true); + } + + private void authorizeAttachment(SessionRecord session, boolean cancellation, + boolean actionResponse) { ContextBinding binding = session.workspace(); if (binding == null || !"ACTIVE".equals(session.status()) || session.deletedAt() != null || !"qwen-code".equals(session.agentId()) @@ -79,7 +87,9 @@ private void authorizeAttachment(SessionRecord session, boolean cancellation) { throw unavailable(); } WorkspaceStorageKindGuard.requireLocalAlias(jdbc, binding.getTenantId(), binding.getStorageId()); - List grants = jdbc.query("SELECT s.tenant_id, s.session_id," + // Verdicts per matched row: 0 structural mismatch, 1 refused only by + // operator-mutable grant or registry state, 2 authorized. + List grants = jdbc.query("SELECT s.tenant_id, s.session_id," + " s.agent_id AS session_agent, s.status AS session_status," + " s.deleted_at AS session_deleted_at," + " s.workspace_id AS session_workspace," @@ -108,38 +118,54 @@ private void authorizeAttachment(SessionRecord session, boolean cancellation) { + " JOIN managed_workspace_access a ON a.tenant_id = r.tenant_id" + " AND a.workspace_id = r.workspace_id AND a.actor_id = c.actor_id") + " WHERE s.tenant_id = ? AND s.session_id = ?", - (row, index) -> session.tenantId().equals(row.getString("tenant_id")) - && session.sessionId().equals(row.getString("session_id")) - && "qwen-code".equals(row.getString("session_agent")) - && "ACTIVE".equals(row.getString("session_status")) - && row.getObject("session_deleted_at") == null - && binding.getWorkspaceId().equals(row.getString("session_workspace")) - && binding.getWorkspaceGeneration() == row.getLong("session_generation") - && binding.getStorageId().equals(row.getString("session_storage")) - && binding.getCwdRelative().equals(row.getString("session_cwd")) - && binding.getContextConfigRef().equals(row.getString("session_context")) - && binding.getContextRevision() == row.getLong("session_revision") - && WorkspaceExecutionProfile.CONFIG_REF.equals( - row.getString("workspace_config_ref")) - && WorkspaceExecutionProfile.POLICY_REF.equals( - row.getString("workspace_policy_ref")) - // Cancellation was authorized when it was persisted; - // retries must not depend on mutable creation grants. - && (cancellation - ? session.tenantId().equals(row.getString("cancellation_tenant")) - && session.sessionId().equals(row.getString("cancellation_session")) - : session.tenantId().equals(row.getString("registry_tenant")) - && session.tenantId().equals(row.getString("command_tenant")) - && session.sessionId().equals(row.getString("command_session")) - && session.tenantId().equals(row.getString("access_tenant")) - && binding.getWorkspaceId().equals(row.getString("workspace_id")) - && binding.getWorkspaceId().equals(row.getString("access_workspace")) - && binding.getWorkspaceGeneration() == row.getLong("workspace_generation") - && binding.getStorageId().equals(row.getString("storage_id")) - && "ACTIVE".equals(row.getString("state")) - && row.getBoolean("can_read") && row.getBoolean("can_create")), + (row, index) -> { + boolean structural = session.tenantId().equals(row.getString("tenant_id")) + && session.sessionId().equals(row.getString("session_id")) + && "qwen-code".equals(row.getString("session_agent")) + && "ACTIVE".equals(row.getString("session_status")) + && row.getObject("session_deleted_at") == null + && binding.getWorkspaceId().equals(row.getString("session_workspace")) + && binding.getWorkspaceGeneration() == row.getLong("session_generation") + && binding.getStorageId().equals(row.getString("session_storage")) + && binding.getCwdRelative().equals(row.getString("session_cwd")) + && binding.getContextConfigRef().equals(row.getString("session_context")) + && binding.getContextRevision() == row.getLong("session_revision") + && WorkspaceExecutionProfile.CONFIG_REF.equals( + row.getString("workspace_config_ref")) + && WorkspaceExecutionProfile.POLICY_REF.equals( + row.getString("workspace_policy_ref")) + // Cancellation was authorized when it was persisted; + // retries must not depend on mutable creation grants. + && (cancellation + ? session.tenantId().equals(row.getString("cancellation_tenant")) + && session.sessionId().equals(row.getString("cancellation_session")) + : session.tenantId().equals(row.getString("registry_tenant")) + && session.tenantId().equals(row.getString("command_tenant")) + && session.sessionId().equals(row.getString("command_session")) + && session.tenantId().equals(row.getString("access_tenant")) + && binding.getWorkspaceId().equals(row.getString("workspace_id")) + && binding.getWorkspaceId().equals(row.getString("access_workspace")) + && binding.getWorkspaceGeneration() == row.getLong("workspace_generation") + && binding.getStorageId().equals(row.getString("storage_id"))); + if (!structural) { + return 0; + } + // Grants and registry state are operator-mutable: a + // refusal stemming only from them is not the structural + // verdict the terminal exit promises. + return cancellation + || ("ACTIVE".equals(row.getString("state")) + && row.getBoolean("can_read") && row.getBoolean("can_create")) + ? 2 : 1; + }, session.tenantId(), session.sessionId()); - if (grants.size() != 1 || !grants.getFirst()) { + if (grants.size() != 1) { + throw unavailable(); + } + if (grants.getFirst() == 1) { + throw actionResponse ? unavailablePendingGrant() : unavailable(); + } + if (grants.getFirst() != 2) { throw unavailable(); } } @@ -324,6 +350,16 @@ public static RuntimeBrokerException unavailableTransient( cause); } + // A creation grant or the registry state changes under operator + // control: refusing a committed decision over it is not the + // structural verdict of unavailable(), and the delivery machine must + // retry so a restored grant still reaches the Harness. Structural + // refusals keep unavailable(). + public static RuntimeBrokerException unavailablePendingGrant() { + return new RuntimeBrokerException(409, "workspace_unavailable", + "Workspace access grant or registry state is changing.", true); + } + private static RuntimeBrokerException busy() { return new RuntimeBrokerException(409, "workspace_busy", "Workspace storage is held by another tool turn.", true); diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java index d55ed6bbef7..4829308f620 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java @@ -425,7 +425,7 @@ void resolvesActionsThroughAuthorizedColdAndCachedWorkspaceAttachments() { .put("inputRevision", 7L).put("policyRevision", "hosted-tool-approval/1"); doThrow(WorkspaceExecutionStore.unavailable()).doNothing() - .when(execution).authorizePassiveAttachment(session); + .when(execution).authorizeActionResponse(session); assertThatThrownBy(() -> connector.resolveAction("tenant-a", SESSION_ID, actionId, response)) .hasMessageContaining("Workspace execution authority is unavailable"); verify(client, never()).loadSession(any()); @@ -451,7 +451,7 @@ void resolvesActionsThroughAuthorizedColdAndCachedWorkspaceAttachments() { verify(execution, times(2)).verifyMountForProbe(session.workspace()); verify(client, never()).createSession(any()); - doThrow(WorkspaceExecutionStore.unavailable()).when(execution).authorizePassiveAttachment(session); + doThrow(WorkspaceExecutionStore.unavailable()).when(execution).authorizeActionResponse(session); assertThatThrownBy(() -> connector.resolveAction("tenant-a", SESSION_ID, actionId, response)) .hasMessageContaining("Workspace execution authority is unavailable"); verify(client, times(1)).resolveAction(any(), any(), any(), anyLong(), any()); diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceStorageGuardTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceStorageGuardTest.java index cf805d6313e..d6f63cb85f7 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceStorageGuardTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceStorageGuardTest.java @@ -19,6 +19,7 @@ import com.alibaba.qwen.code.managedagent.harness.QwenHostedHarnessConnector; import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionRecord; import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; +import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; import com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository; import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding; import com.fasterxml.jackson.databind.ObjectMapper; @@ -264,6 +265,7 @@ void actionResponseRetriesAMomentaryMountReadWithoutChangingAcquireVerdicts() { null, "ACTIVE", null, null, 0, 0, 0, 1, 1, null, 1, binding, "default", "hosted-workspace-files/1"); var execution = spy(new WorkspaceExecutionStore(jdbc, manager, guard)); + doNothing().when(execution).authorizeActionResponse(session); doNothing().when(execution).authorizePassiveAttachment(session); var sessions = mock(AgentStateStore.class); when(sessions.requireSession("tenant", sessionId)).thenReturn(session); @@ -299,6 +301,95 @@ void actionResponseRetriesAMomentaryMountReadWithoutChangingAcquireVerdicts() { verify(client).resolveAction(attached, "action", "allow", 1L, "policy"); } + @Test + void actionResponseRetriesARevokedGrantWhileKeepingStructuralRefusalsTerminal() { + var manager = new DataSourceTransactionManager(dataSource); + var guard = new WorkspaceStorageGuard(jdbc, manager, properties, path -> + new WorkspaceStorageGuard.Identity(path.toString(), "host", "device", "inode", + "2026-10-07T00:00:00Z")); + guard.register("tenant", "storage", UUID.randomUUID().toString()); + // The action-response authority is derived from the real grant rows, + // not injected. + var execution = new WorkspaceExecutionStore(jdbc, manager, guard); + var bound = new ContextBinding("tenant", "workspace", 1, "storage", "child", + WorkspaceExecutionProfile.CONTEXT_CONFIG_REF, 1); + String sessionId = UUID.randomUUID().toString(); + jdbc.update("INSERT INTO managed_agent_session (tenant_id, session_id, agent_id, status," + + " created_at, updated_at, workspace_id, workspace_generation, workspace_storage_id," + + " cwd_relative, context_config_ref, context_revision, workspace_config_ref," + + " workspace_policy_ref) VALUES ('tenant', ?, 'qwen-code', 'ACTIVE', 0, 0," + + " 'workspace', 1, 'storage', 'child', ?, 1, ?, ?)", + sessionId, WorkspaceExecutionProfile.CONTEXT_CONFIG_REF, + WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF); + jdbc.update("INSERT INTO managed_workspace_registry (tenant_id, workspace_id," + + " workspace_generation, storage_id, display_name, config_ref, policy_ref, state)" + + " VALUES ('tenant', 'workspace', 1, 'storage', 'workspace', ?, ?, 'ACTIVE')", + WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF); + jdbc.update("INSERT INTO managed_workspace_create_command (tenant_id, actor_id," + + " idempotency_key, request_digest, session_id, created_at)" + + " VALUES ('tenant', ?, 'create-1', 'digest', ?, 0)", + "owner".getBytes(java.nio.charset.StandardCharsets.UTF_8), sessionId); + jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id," + + " can_read, can_create) VALUES ('tenant', 'workspace', ?, TRUE, TRUE)", + "owner".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + var session = new SessionRecord("tenant", sessionId, "qwen-code", null, + null, "ACTIVE", null, null, 0, 0, 0, 1, 1, null, 1, + bound, "default", "hosted-workspace-files/1"); + var sessions = mock(AgentStateStore.class); + when(sessions.requireSession("tenant", sessionId)).thenReturn(session); + var actions = mock(ManagedActionStore.class); + when(actions.approvalMode("tenant", sessionId)).thenReturn("default"); + properties.getHarness().setToken("test-token"); + properties.getHarness().setCapabilityDigest("sha256:" + "a".repeat(64)); + properties.getHarness().setWorkspaceFilesEnabled(true); + var connector = new QwenHostedHarnessConnector(properties, sessions, execution, actions); + var client = mock(HostedHarnessClient.class); + var capabilities = mock(HostedHarnessCapabilities.class); + when(client.capabilities()).thenReturn(capabilities); + when(capabilities.getBootId()).thenReturn(UUID.randomUUID().toString()); + var attached = mock(HarnessSessionRef.class); + when(attached.getApprovalMode()).thenReturn("default"); + when(client.loadSession(any())).thenReturn(attached); + ReflectionTestUtils.setField(connector, "client", client); + var response = new ObjectMapper().createObjectNode().put("optionId", "allow") + .put("inputRevision", 1).put("policyRevision", "policy"); + // A revoked creation grant is operator-reversible: the refusal must + // stay retryable so a restored grant still delivers the answer. + jdbc.update("UPDATE managed_workspace_access SET can_create = FALSE" + + " WHERE tenant_id = 'tenant' AND workspace_id = 'workspace'"); + assertThatThrownBy(() -> connector.resolveAction("tenant", sessionId, "action", response)) + .isInstanceOfSatisfying(RuntimeBrokerException.class, error -> { + assertThat(error.getCode()).isEqualTo("workspace_unavailable"); + assertThat(error.isRetryable()).isTrue(); + }); + // So is a registry that left ACTIVE. + jdbc.update("UPDATE managed_workspace_access SET can_create = TRUE" + + " WHERE tenant_id = 'tenant' AND workspace_id = 'workspace'"); + jdbc.update("UPDATE managed_workspace_registry SET state = 'DRAINING'" + + " WHERE tenant_id = 'tenant' AND workspace_id = 'workspace'"); + assertThatThrownBy(() -> connector.resolveAction("tenant", sessionId, "action", response)) + .isInstanceOfSatisfying(RuntimeBrokerException.class, error -> { + assertThat(error.getCode()).isEqualTo("workspace_unavailable"); + assertThat(error.isRetryable()).isTrue(); + }); + verify(client, never()).resolveAction(any(), any(), any(), anyLong(), any()); + // Restored, the committed decision reaches the Harness. + jdbc.update("UPDATE managed_workspace_registry SET state = 'ACTIVE'" + + " WHERE tenant_id = 'tenant' AND workspace_id = 'workspace'"); + connector.resolveAction("tenant", sessionId, "action", response); + verify(client).resolveAction(attached, "action", "allow", 1L, "policy"); + // Generation drift after a re-registration stays structural: the + // terminal exit keeps its verdict. + jdbc.update("UPDATE managed_workspace_registry SET workspace_generation = 2" + + " WHERE tenant_id = 'tenant' AND workspace_id = 'workspace'"); + assertThatThrownBy(() -> connector.resolveAction("tenant", sessionId, "action", response)) + .isInstanceOfSatisfying(RuntimeBrokerException.class, error -> { + assertThat(error.getCode()).isEqualTo("workspace_unavailable"); + assertThat(error.isRetryable()).isFalse(); + }); + verify(client).resolveAction(any(), any(), any(), anyLong(), any()); + } + @Test void refusesDirectoryAndSymlinkMarkersWithoutChangingRegistration() throws Exception { String operation = UUID.randomUUID().toString(); From 2740c230d24460334026f894623a0d38dde73c7b Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 8 Oct 2026 10:57:12 +0800 Subject: [PATCH 68/73] fix(cli): refuse a resident inapplicable redrive while a prompt slot is active The helper re-validated registration and closing after its awaits but never that the Session was still idle, and the latch clear removed the only incidentally closing guard: a files/rewind admitted inside the window had its prompt slot overwritten by the settle projection and its AbortController dropped while /status reported the Session idle. Add the third post-await re-validation the continue and managed-runtime cancel routes already perform, answering the session-scoped refusal before the projection can run. --- .../src/serve/hosted-harness-session.test.ts | 140 ++++++++++++++++++ .../cli/src/serve/hosted-harness-session.ts | 5 + 2 files changed, 145 insertions(+) diff --git a/packages/cli/src/serve/hosted-harness-session.test.ts b/packages/cli/src/serve/hosted-harness-session.test.ts index d966dd4367e..60b9afd09a1 100644 --- a/packages/cli/src/serve/hosted-harness-session.test.ts +++ b/packages/cli/src/serve/hosted-harness-session.test.ts @@ -9831,6 +9831,146 @@ describe('Hosted Harness Runtime turn takeover', () => { }, ); + it('refuses a resident inapplicable redrive while a rewind owns the prompt slot', async () => { + const realAuthorization = + LocalManagedSessionAuthority.prototype.harnessRunAuthorization; + await parkToolTurn(); + vi.mocked(HostedWorkspaceBroker.prototype.execute).mockResolvedValue({ + executionStatus: 'success', + responseParts: [{ text: 'written' }], + } as never); + const { server, loaded } = await loadReplacement(); + const recovery = loaded.body._meta['qwen.daemon.managedRuntimeRecovery']; + const originalWrite = ManagedSessionRecordSink.prototype.write; + const write = vi + .spyOn(ManagedSessionRecordSink.prototype, 'write') + .mockImplementation(function (this: ManagedSessionRecordSink, item) { + if (item.subtype === 'turn_result') + return Promise.reject(new Error('terminal write unavailable')); + return originalWrite.call(this, item); + }); + await replacementHeaders( + supertest(server).post(`/session/${SESSION_ID}/managed-runtime/continue`), + ) + .set('X-Qwen-Client-Id', loaded.body.clientId) + .send({ + promptId: PROMPT_ID, + checkpointId: recovery.checkpointId, + activationId: recovery.activationId, + }) + .expect(200); + await vi.waitFor(async () => { + const status = await replacementHeaders( + supertest(server).get(`/session/${SESSION_ID}/status`), + ).set('X-Qwen-Client-Id', loaded.body.clientId); + expect(status.body.hasActivePrompt).toBe(false); + expect(status.body.recoveryBlocked).toBe(true); + }); + write.mockRestore(); + // An approval-pending redrive answers the inapplicable resident without + // a projection, clearing the resident latch. + mockAuthorizationWithPhase('await_approval', { state: 'requested' }); + const armed = await replacementHeaders( + supertest(server).post(`/session/${SESSION_ID}/load`), + ).send({ + managedSessionStore: storeFor(BOOT_ID_2), + toolProfile: FILE_PROFILE, + driveRuntimeRecovery: true, + }); + expect(armed.status).toBe(200); + // The next redrive turns payable: hold it inside + // settleProjectablePromptId, admit a rewind inside the window, and + // only then release the held read. + let resumeSettle!: () => void; + const settleHeld = new Promise((resolve) => { + resumeSettle = resolve; + }); + let settleParked = false; + let helperAnswered = false; + vi.mocked( + LocalManagedSessionAuthority.prototype.harnessRunAuthorization, + ).mockImplementation(async function (this: LocalManagedSessionAuthority) { + if (helperAnswered && !settleParked) { + settleParked = true; + await settleHeld; + } + const authorization = await realAuthorization.call(this); + helperAnswered = true; + if (authorization.status !== 'runnable') return authorization; + return { + ...authorization, + checkpoint: { + ...authorization.checkpoint, + continuation: { + ...authorization.checkpoint.continuation, + phase: 'turn_settled', + }, + approval: null, + }, + } as never; + }); + const redriven = replacementHeaders( + supertest(server).post(`/session/${SESSION_ID}/load`), + ) + .send({ + managedSessionStore: storeFor(BOOT_ID_2), + toolProfile: FILE_PROFILE, + driveRuntimeRecovery: true, + }) + .then((response) => response); + await vi.waitFor(() => { + expect(settleParked).toBe(true); + }); + // The rewind's own history read is the only resource read in flight + // now, so gating it parks the rewind after it took the prompt slot. + let resumeRewindRead!: () => void; + const rewindReadHeld = new Promise((resolve) => { + resumeRewindRead = resolve; + }); + let rewindParked = false; + const realRead = LocalManagedSessionResourceStore.prototype.read; + const readSpy = vi + .spyOn(LocalManagedSessionResourceStore.prototype, 'read') + .mockImplementation(async function ( + this: LocalManagedSessionResourceStore, + ...args: Parameters + ) { + if (!rewindParked) { + rewindParked = true; + await rewindReadHeld; + } + return realRead.apply(this, args); + } as never); + const rewind = replacementHeaders( + supertest(server).post(`/session/${SESSION_ID}/files/rewind`), + ) + .set('X-Qwen-Client-Id', armed.body.clientId) + .send({ requestId: randomUUID(), promptId: PROMPT_ID }) + .then((response) => response); + await vi.waitFor(() => { + expect(rewindParked).toBe(true); + }); + resumeSettle(); + const refused = await redriven; + expect(refused.status).toBe(409); + expect(refused.body.code).toBe('hosted_turn_active'); + // The rewind must keep its prompt slot: /status may never report the + // Session idle while a workspace mutation is in flight. + const during = await replacementHeaders( + supertest(server).get(`/session/${SESSION_ID}/status`), + ).set('X-Qwen-Client-Id', armed.body.clientId); + expect(during.body.hasActivePrompt).toBe(true); + resumeRewindRead(); + readSpy.mockRestore(); + await rewind; + await vi.waitFor(async () => { + const status = await replacementHeaders( + supertest(server).get(`/session/${SESSION_ID}/status`), + ).set('X-Qwen-Client-Id', armed.body.clientId); + expect(status.body.hasActivePrompt).toBe(false); + }); + }); + it('refuses a cancellation takeover attachment deleted during settlement', async () => { const { server } = await parkToolTurn(true); const authorize = diff --git a/packages/cli/src/serve/hosted-harness-session.ts b/packages/cli/src/serve/hosted-harness-session.ts index 8d42f26d4ff..a91cf54b479 100644 --- a/packages/cli/src/serve/hosted-harness-session.ts +++ b/packages/cli/src/serve/hosted-harness-session.ts @@ -1827,6 +1827,11 @@ export function registerHostedHarnessSessionRoutes( error(res, 409, 'hosted_session_closing'); return; } + if (resident.active !== undefined) { + noteOwedAdoption(resident, sessionId); + error(res, 409, 'hosted_turn_active'); + return; + } refusedAdoptions.delete(sessionId); resident.blocked = false; sendAttachment(res, sessionId, resident); From 576a39a90bd08cce1dd9ca3cf928e719a33b7dde Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 8 Oct 2026 12:52:37 +0900 Subject: [PATCH 69/73] test(managed-agent): pin V52 after renumbering the mutation-attempt migration --- .../qwen/code/managedagent/store/WorkspaceMigrationMySqlIT.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceMigrationMySqlIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceMigrationMySqlIT.java index 32a4c67dd4a..33247cb3f44 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceMigrationMySqlIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceMigrationMySqlIT.java @@ -62,7 +62,7 @@ void upgradesCurrentMainWithoutChangingAppliedMigrations() { + " WHERE installed_rank <= ? ORDER BY installed_rank", lastRank)).isEqualTo(applied); assertThat(jdbc.queryForList("SELECT version FROM flyway_schema_history" + " WHERE installed_rank > ? AND success = TRUE ORDER BY installed_rank", - String.class, lastRank)).containsExactly("48", "49", "50", "51"); + String.class, lastRank)).containsExactly("48", "49", "50", "51", "52"); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_workspace_migration", Integer.class)).isZero(); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM qwen_runtime_storage_fence", Integer.class)).isZero(); } From 00be8ed0ff12b370a0bbac6bae29cde163f50e55 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 8 Oct 2026 12:59:39 +0800 Subject: [PATCH 70/73] fix(managed-agent): preserve approval retries during cold attachment --- .../harness/QwenHostedHarnessConnector.java | 20 +++++++++++++++---- .../store/WorkspaceStorageGuardTest.java | 14 ++++++++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java index 02680440352..60324a1a0c2 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java @@ -209,7 +209,7 @@ public Attachment createOrLoad(String tenantId, String sessionId, boolean loadExisting, boolean passiveManagedRuntimeRecovery) { try { return doCreateOrLoad(tenantId, sessionId, loadExisting, - passiveManagedRuntimeRecovery); + passiveManagedRuntimeRecovery, false); } catch (HostedHarnessGenerationException error) { adoptGeneration(error); throw error; @@ -217,7 +217,8 @@ public Attachment createOrLoad(String tenantId, String sessionId, } private Attachment doCreateOrLoad(String tenantId, String sessionId, - boolean loadExisting, boolean passiveManagedRuntimeRecovery) { + boolean loadExisting, boolean passiveManagedRuntimeRecovery, + boolean actionResponse) { SessionRecord session = sessions.requireSession(tenantId, sessionId); if (session.workspace() != null) { if (!isWorkspaceFilesAvailable()) { @@ -227,7 +228,12 @@ private Attachment doCreateOrLoad(String tenantId, String sessionId, throw new IllegalStateException("Hosted Workspace Sessions" + " require the Managed Action store"); } - if (passiveManagedRuntimeRecovery) { + if (actionResponse) { + workspaceExecution.authorizeActionResponse(session); + if (!passiveManagedRuntimeRecovery) { + workspaceExecution.verifyMountForProbe(session.workspace()); + } + } else if (passiveManagedRuntimeRecovery) { workspaceExecution.authorizePassiveAttachment(session); } else { workspaceExecution.authorize(session); @@ -426,7 +432,13 @@ private void doResolveAction( String actionId, JsonNode response) { requireReadyForNewWork(tenantId, sessionId, true); - HarnessSessionRef ref = attachment(tenantId, sessionId, false); + AttachmentKey key = new AttachmentKey(tenantId, sessionId); + HarnessSessionRef ref = attachments.get(key); + if (ref == null) { + doCreateOrLoad(tenantId, sessionId, true, + workspaceExecution.verifiedRecoveryEnabled(), true); + ref = attachments.get(key); + } client().resolveAction( ref, actionId, diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceStorageGuardTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceStorageGuardTest.java index d6f63cb85f7..cac54d3817f 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceStorageGuardTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceStorageGuardTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -310,7 +311,7 @@ void actionResponseRetriesARevokedGrantWhileKeepingStructuralRefusalsTerminal() guard.register("tenant", "storage", UUID.randomUUID().toString()); // The action-response authority is derived from the real grant rows, // not injected. - var execution = new WorkspaceExecutionStore(jdbc, manager, guard); + var execution = spy(new WorkspaceExecutionStore(jdbc, manager, guard)); var bound = new ContextBinding("tenant", "workspace", 1, "storage", "child", WorkspaceExecutionProfile.CONTEXT_CONFIG_REF, 1); String sessionId = UUID.randomUUID().toString(); @@ -353,6 +354,17 @@ void actionResponseRetriesARevokedGrantWhileKeepingStructuralRefusalsTerminal() ReflectionTestUtils.setField(connector, "client", client); var response = new ObjectMapper().createObjectNode().put("optionId", "allow") .put("inputRevision", 1).put("policyRevision", "policy"); + doAnswer(invocation -> { + invocation.callRealMethod(); + assertThat(jdbc.update("UPDATE managed_workspace_access SET can_create = FALSE" + + " WHERE tenant_id = 'tenant' AND workspace_id = 'workspace'")).isEqualTo(1); + return null; + }).doCallRealMethod().when(execution).authorizeActionResponse(session); + assertThatThrownBy(() -> connector.resolveAction("tenant", sessionId, "action", response)) + .isInstanceOfSatisfying(RuntimeBrokerException.class, error -> { + assertThat(error.getCode()).isEqualTo("workspace_unavailable"); + assertThat(error.isRetryable()).isTrue(); + }); // A revoked creation grant is operator-reversible: the refusal must // stay retryable so a restored grant still delivers the answer. jdbc.update("UPDATE managed_workspace_access SET can_create = FALSE" From f52f1236ab4012633de0733bf51220a421f551cf Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 8 Oct 2026 14:36:56 +0800 Subject: [PATCH 71/73] test(managed-agent): pin the legacy receipt rewrite and the acquire verdict control - Wrap the two raw jdbc receipt mutations in exactly-one-row assertions so a 0-row arm can no longer collapse onto the tested path. - Add the positive acquire-path control after the action-response probe retry proves its retryable refusal: the same mount must still authorize without a verdict change. Review threads on the 2026-10-07 automated closeout round. --- .../code/managedagent/ManagedSessionLifecycleTest.java | 8 ++++---- .../managedagent/store/WorkspaceStorageGuardTest.java | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java index da974301173..b9959403e77 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java @@ -518,10 +518,10 @@ void retiredRenameCannotCompleteOverALaterCompletedRename(boolean legacyReceipt) sessionId, SessionMutationKind.RENAME); store.abandonSessionMutation(tenant, "RENAME_SESSION", "k1", sessionId); if (legacyReceipt) { - jdbc.update("UPDATE managed_agent_command SET mutation_attempt_sequence" + assertThat(jdbc.update("UPDATE managed_agent_command SET mutation_attempt_sequence" + " = NULL WHERE tenant_id = ? AND operation = ?" + " AND idempotency_key = ?", - tenant, "RENAME_SESSION", "k1"); + tenant, "RENAME_SESSION", "k1")).isEqualTo(1); } store.beginSessionMutation(tenant, "RENAME_SESSION", "k2", second, sessionId, SessionMutationKind.RENAME); @@ -576,9 +576,9 @@ void latestRenameAttemptCompletesAfterItsSiblingRetires(boolean recreatedReceipt store.completeSessionMutation(tenant, "RENAME_SESSION", "k2", sessionId, SessionMutationKind.RENAME, "B", bootId); if (recreatedReceipt) { - jdbc.update("DELETE FROM managed_agent_command WHERE tenant_id = ?" + assertThat(jdbc.update("DELETE FROM managed_agent_command WHERE tenant_id = ?" + " AND operation = ? AND idempotency_key = ?", - tenant, "RENAME_SESSION", "k1"); + tenant, "RENAME_SESSION", "k1")).isEqualTo(1); } store.beginSessionMutation(tenant, "RENAME_SESSION", "k1", first, sessionId, SessionMutationKind.RENAME); diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceStorageGuardTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceStorageGuardTest.java index cac54d3817f..31b9a1f45f4 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceStorageGuardTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceStorageGuardTest.java @@ -300,6 +300,9 @@ void actionResponseRetriesAMomentaryMountReadWithoutChangingAcquireVerdicts() { unreadable.set(0); connector.resolveAction("tenant", sessionId, "action", response); verify(client).resolveAction(attached, "action", "allow", 1L, "policy"); + // Positive control: the probe-only retry leaves the shared acquire + // path's verdict untouched — a settled mount still authorizes. + guard.verify(binding); } @Test From 7867f4b8693483fa79aee6b3184255ae26272f5b Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 8 Oct 2026 15:32:37 +0800 Subject: [PATCH 72/73] test(sdk-java): allow heartbeat recovery time after refused detach Keep the confirmed-detach observation at 200ms, but allow rejected detach responses two seconds to demonstrate continued HTTP heartbeats on macOS. Co-authored-by: Qwen-Coder --- .../com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java index 2329395b5f6..8640f828efa 100644 --- a/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java +++ b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java @@ -119,7 +119,8 @@ void lifecycleDetachStopsHeartbeatOnlyAfterConfirmedAbsence(boolean byId, int st assertThrows(expected, detach::run); } detached.set(true); - assertEquals(!confirmed, laterHeartbeats.await(200, TimeUnit.MILLISECONDS)); + assertEquals(!confirmed, laterHeartbeats.await( + confirmed ? 200 : 2000, TimeUnit.MILLISECONDS)); } } From 39267a90f2a05b8940db7676fea1f2237678ae62 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 8 Oct 2026 16:19:44 +0800 Subject: [PATCH 73/73] test(hosted): pin teardown and pending rename recovery guards Add HTTP-level deletion and closing interleavings for resident inapplicable recovery, and a public-store pending rename witness. Keep the inherited main heartbeat deadline fix unchanged. Co-authored-by: Qwen-Coder --- .../src/serve/hosted-harness-session.test.ts | 90 +++++++++++++++++++ .../ManagedSessionLifecycleTest.java | 21 +++++ 2 files changed, 111 insertions(+) diff --git a/packages/cli/src/serve/hosted-harness-session.test.ts b/packages/cli/src/serve/hosted-harness-session.test.ts index f77152378d6..3ccc52b85e5 100644 --- a/packages/cli/src/serve/hosted-harness-session.test.ts +++ b/packages/cli/src/serve/hosted-harness-session.test.ts @@ -11006,6 +11006,96 @@ describe('Hosted Harness Runtime turn takeover', () => { }); }); + it.each(['removed', 'closing'] as const)( + 'refuses a resident inapplicable redrive when its attachment is %s', + async (attachmentState) => { + await parkToolTurn(); + vi.mocked(HostedWorkspaceBroker.prototype.execute).mockResolvedValue({ + executionStatus: 'success', + responseParts: [{ text: 'written' }], + } as never); + const { server, loaded } = await loadReplacement(); + expect(loaded.status).toBe(200); + const originalAuthorization = + LocalManagedSessionAuthority.prototype.harnessRunAuthorization; + let resumeAuthorization!: () => void; + const authorizationGate = new Promise((resolve) => { + resumeAuthorization = resolve; + }); + let authorizationCalls = 0; + let authorizationHeld = false; + vi.spyOn( + LocalManagedSessionAuthority.prototype, + 'harnessRunAuthorization', + ).mockImplementation(async function (this: LocalManagedSessionAuthority) { + const authorization = await originalAuthorization.call(this); + if (authorization.status !== 'runnable') return authorization; + if (++authorizationCalls === 2) { + authorizationHeld = true; + await authorizationGate; + } + return { + ...authorization, + checkpoint: { + ...authorization.checkpoint, + continuation: { + ...authorization.checkpoint.continuation, + phase: 'await_approval', + }, + approval: { state: 'requested' }, + }, + } as never; + }); + let resumeClose!: () => void; + const closeGate = new Promise((resolve) => { + resumeClose = resolve; + }); + let closeHeld = false; + if (attachmentState === 'closing') { + vi.mocked( + HostedWorkspaceBroker.prototype.release, + ).mockImplementationOnce(async () => { + closeHeld = true; + await closeGate; + }); + } + const load = replacementHeaders( + supertest(server).post(`/session/${SESSION_ID}/load`), + ) + .send({ + managedSessionStore: storeFor(BOOT_ID_2), + toolProfile: FILE_PROFILE, + driveRuntimeRecovery: true, + }) + .then((response) => response); + let close: Promise | undefined; + try { + await vi.waitFor(() => expect(authorizationHeld).toBe(true)); + close = replacementHeaders( + supertest(server).delete(`/session/${SESSION_ID}`), + ).then((response) => response); + if (attachmentState === 'closing') { + await vi.waitFor(() => expect(closeHeld).toBe(true)); + } else { + expect((await close).status).toBe(204); + } + resumeAuthorization(); + const refused = await load; + expect(refused.status).toBe(attachmentState === 'removed' ? 404 : 409); + expect(refused.body.code).toBe( + attachmentState === 'removed' + ? 'hosted_session_not_found' + : 'hosted_session_closing', + ); + } finally { + resumeAuthorization(); + resumeClose(); + await Promise.allSettled(close ? [load, close] : [load]); + } + expect((await close!).status).toBe(204); + }, + ); + it('refuses a cancellation takeover attachment deleted during settlement', async () => { const { server } = await parkToolTurn(true); const authorize = diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java index b9959403e77..681cb194d58 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java @@ -555,6 +555,27 @@ void retiredRenameCannotCompleteOverALaterCompletedRename(boolean legacyReceipt) .andExpect(jsonPath("$.metadata.title").value("A")); } + @Test + void pendingRenameCompletesAfterAnOlderRetiredSibling() throws Exception { + String tenant = tenant(); + String sessionId = attachedSession(tenant); + String bootId = store.requireSession(tenant, sessionId).harnessBootId(); + store.beginSessionMutation(tenant, "RENAME_SESSION", "k1", "first", + sessionId, SessionMutationKind.RENAME); + store.beginSessionMutation(tenant, "RENAME_SESSION", "k1", "first", + sessionId, SessionMutationKind.RENAME); + store.abandonSessionMutation(tenant, "RENAME_SESSION", "k1", sessionId); + store.beginSessionMutation(tenant, "RENAME_SESSION", "k2", "second", + sessionId, SessionMutationKind.RENAME); + assertThat(store.completeSessionMutation(tenant, "RENAME_SESSION", "k1", + sessionId, SessionMutationKind.RENAME, "A", bootId).title()) + .isEqualTo("A"); + assertThat(store.completeSessionMutation(tenant, "RENAME_SESSION", "k2", + sessionId, SessionMutationKind.RENAME, "B", bootId).title()) + .isEqualTo("B"); + assertThat(store.requireSession(tenant, sessionId).title()).isEqualTo("B"); + } + @ParameterizedTest @ValueSource(booleans = {false, true}) void latestRenameAttemptCompletesAfterItsSiblingRetires(boolean recreatedReceipt)