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
-
- {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