feat(managed-agent): let a Workspace-bound Session's creator submit, cancel and rename (#13112)

* 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.

* feat(web-shell): let the creator send later Turns to a bound Session

The server now admits later Turns from a Workspace-bound Session's
creator, but the Managed panel still hid the composer for every bound
Session and the Java provider forced canSend to false.

- WebShellSessionCapabilities gains workspaceTurns, computed per Session
  and caller with the same rule the submit path enforces (opt-in on,
  caller can read, caller created the Session). The contract and the
  generated WebShell types describe it.
- The Java provider sends for a bound Session only when the service
  reports workspaceTurns, and reports it in the summary only when true.
- The Managed panel shows the composer for such a Session; cancel stays
  hidden for bound Sessions.
- HostedPublicWorkspaceIT checks the capability per caller through the
  WebShell route; the contract test's WebShell capability shape and
  provider/page tests cover the client.

* feat(managed-agent): let the bound Session creator cancel a running Turn

Later Turns of a Workspace-bound Session could be submitted by the
creator but not cancelled: the service and store refused the cancel,
and the coordinator skipped bound Sessions entirely.

Apply the same rule to cancellation. The creator, with the Workspace-
files opt-in on, may cancel; the coordinator then cancels through the
Hosted Harness like any other Turn, which aborts the Turn and settles
its Runtime calls through their original identities. Other actors and
deployments without the opt-in keep the existing refusal, and the
coordinator still leaves a bound Session alone without the opt-in.

- HarnessCoordinatorTest: without the opt-in only the opt-in is read;
  with it, the bound Turn is cancelled through the Harness.
- HostedPublicWorkspaceIT: a later Turn is held in the model call; a
  reader's cancel is refused, the creator's cancel is accepted, and the
  Turn ends CANCELLED with no tool execution.
- WebShell reports canCancel for such a Session when workspaceTurns is
  set, so the Managed panel shows Cancel.
- Contract text, generated types, README and both G0 design documents
  now keep only lifecycle operations gated.

* feat(managed-agent): let the bound Session creator rename it

Rename of a Workspace-bound Session answered 409 until a role source
decided who may change it (D4 4.9). #12867 settled the first version:
the Session's creator is its only owner. Rename changes only the title
through the Hosted Harness and touches no Runtime, so apply the same
creator rule under the Workspace-files opt-in; the store admits only a
RENAME mutation of a bound Session, and only under the opt-in.

Close, archive, delete and unarchive stay gated: the embedded Broker's
drain only stops warming a closed Session and has no Harness-level
teardown yet, so closing a bound Session would leave its worker and any
held Workspace lease behind.

HostedPublicWorkspaceIT: a reader's rename is refused; the creator's
rename returns the new title.

* style(web-shell): keep the workspaceTurns expression on one line as Prettier prints it

* test(managed-agent): read the renamed title from the public Session metadata

PublicSession carries its title under metadata.title; the rename
assertion read a top-level title and saw an empty string although the
rename returned 200.

* fix(web-shell): match the generated optional workspaceTurns capability

* test(managed-agent): keep the approval run out of the later-Turn checks

* docs(managed-agent): Align G0 gating statements with creator later-Turn admission

The G0 section and Prerequisites still published the pre-follow-up gate
("later submit/cancel remain gated"), contradicting the boundary
paragraph this PR adds; state the creator's submit/cancel/rename
admission and keep close/archive/delete/unarchive and cwd gated. Scope
the follow-up's refusal precisely: requireLegacyWorkspace answers
workspace_unavailable only when the actor can read the Workspace, and
session_not_found otherwise. Apply the same two corrections to the
bilingual design doc and note the follow-up's composer UI change.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuoob5v90j

* chore(managed-agent): Bump the public API contract to 1.27.0

The deferral resolved: #13101 merged and took 1.25.0, and #13117 took
1.26.0. Record this PR's contract change per the version-history
convention: creator later-Turn admission and the workspaceTurns
capability.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuoob5v90j

* fix(web-shell): hide execution-unavailable banner line when creator can send later Turns

The workspace-binding banner always stated that message execution is
unavailable, contradicting the composer this PR enables for the creator
of a bound Session with workspaceTurns capability. Gate the third
banner paragraph on !capabilities.workspaceTurns so the page no longer
states both. Pin both branches in ManagedSessionsPage tests.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuoqgbnv0l

* 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 <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuoqgbnv0l

* 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 <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuoqgbnv0l

* 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 <[email protected]>

* 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.

* 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 <[email protected]>
Patrol-Run: qwen-pr-closeout/jmupkgkl21b

* 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 <[email protected]>
Patrol-Run: qwen-pr-closeout/jmupkgkl21b

* 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 <[email protected]>
Patrol-Run: qwen-pr-closeout/jmupkgkl21b

* 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 <[email protected]>

* fix(managed-agent): retry transient cancel-attach refusals instead of settling the Turn

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 <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuq5w6bb0f

* 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 <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuq5w6bb0f

* 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 <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuqn1gku0t

* 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 <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuqn1gku0t

* fix(managed-agent): clear rename admission when harness is disabled

* test(managed-agent): expect the closed Session status for a queued bound Turn

WorkspaceSessionCloseMySqlIT (#13135) was written before this PR, when a
bound Session refused every later Turn with workspace_unavailable. With
Workspace files enabled this PR admits the creator's later Turns, so the
Turn queued behind the close now reaches the Session status and answers
session_not_active, which is what the test means to show: the close commits
before the queued admission inspects the Session.

* fix(runtime-broker): close drained lost workspace bindings

---------

Co-authored-by: yiliang114 <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: yiliang114 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-03 01:42:31 +00:00
1 parent e8cb82bb1b
commit 2b15eac862
29 files changed
+1266 -130

No files matched your search

@@ -14,9 +14,21 @@ and uses the deployment's global Workspace for every Session Store connection.
G0 enables one initial file-tool Turn admitted with Session creation. It uses
the existing public REST route and the WebShell creation adapter that shares its
service. Later submit, cancel, rename, lifecycle and cwd operations retain their
existing Workspace gates. Discovery continues to advertise only Workspace
binding, not complete Workspace execution support. No UI changes are required.
service. Discovery continues to advertise only
Workspace binding, not complete Workspace execution support. G0 requires no UI
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
settles through the original Runtime identities. Execution authorizes every
Turn against the creator's Workspace grants, so any other actor, and every
deployment without the opt-in, keeps the existing refusal:
`workspace_unavailable` when the actor can read the Workspace,
`session_not_found` when they cannot. The creator may also rename the Session.
Workspace close follows its separate close capability and lifecycle admission.
Archive, delete and unarchive follow the separate retention capabilities after
reliable Workspace close. Cwd operations remain gated for bound Sessions.
## Decisions
@@ -44,6 +56,8 @@ binding, not complete Workspace execution support. No UI changes are required.
- Cold load of unsettled input remains blocked. G0 does not enable in-flight
continuation, adopt workers, remove affinity or change the G1 failover gates.
- A live cancellation reuses its admitted Harness attachment and is retried by the current lease owner. It never certifies a terminal failure from a fresh attach refusal. Recorded rename failures retain a `FAILED` command receipt and digest; same-content retries are replays, conflicting content remains rejected, and a concurrent success can complete the retained receipt.
## Changes and ownership
| Layer | Change | Scope |
@@ -55,18 +69,17 @@ binding, not complete Workspace execution support. No UI changes are required.
| 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`
@@ -92,7 +105,10 @@ 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; close and retention follow their separate capabilities,
while cwd remains gated), and unbound
no-tool regression paths.
Focused SDK serialization, connector, store/admission and coordinator tests
cover create/load identity, authorization rechecks and disabled gates. Run the
@@ -104,7 +120,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.
@@ -8,7 +8,9 @@
Hosted Read/Write/Edit 已能经过生产 Broker 和 worker 执行,但目前只有私有集成测试把它接到持久 Workspace 会话。公开创建在 service 和 SQL store 两层拒绝初始输入;coordinator 也拒绝有绑定的会话。Java connector 不传工具 profile,所有 Session Store 连接均使用部署的全局 Workspace。
G0 开放随创建会话准入的一次初始文件工具轮次,使用现有公开 REST 路由及共享 service 的 WebShell 创建适配器。后续提交、取消、重命名、生命周期与 cwd 操作保持现有 Workspace 门禁。发现接口仍仅广播 Workspace 绑定能力,不宣称完整的 Workspace 执行支持。无需修改 UI。
G0 开放随创建会话准入的一次初始文件工具轮次,使用现有公开 REST 路由及共享 service 的 WebShell 创建适配器。发现接口仍仅广播 Workspace 绑定能力,不宣称完整的 Workspace 执行支持。G0 本身无需修改 UI;后续改动唯一的 UI 变化是为创建者开放输入框与取消控件。
后续改动在同一开关下为会话创建者开放后续 Turn,并允许创建者取消正在运行的 Turn,由 Hosted Harness 中止该 Turn 并按原始 Runtime 身份结算。执行时每个 Turn 都按创建者的 Workspace 授权校验,因此其他 actor 以及未开启该开关的部署仍得到现有拒绝:actor 可读取该 Workspace 时为 `workspace_unavailable`,不能读取时为 `session_not_found`。创建者也可以重命名该会话。工作区关闭通过独立的关闭能力及生命周期准入控制;归档、删除与取消归档遵循可靠工作区关闭后的独立保留能力;cwd 操作仍保持门禁。
## 决策
@@ -19,6 +21,8 @@ G0 开放随创建会话准入的一次初始文件工具轮次,使用现有
- 私有 create 与 load 都传递所选 profile 及持久 Workspace ID,包括创建冲突和创建结果不明后回退到 load 的路径。Harness 现有的不可变 definition 检查固定 profile。
- 冷加载未结算输入仍被阻塞。G0 不启用在飞续接、接管 worker、取消 owner 粘性,也不改动 G1 failover 门禁。
- 取消运行中的 Turn 复用已准入的 Harness 连接,并由当前租约 owner 重试,不根据重新连接的拒绝伪造终态失败。已记录的重命名失败保留 `FAILED` 命令回执与摘要;相同内容重试仍是重放,不同内容继续冲突,并发成功请求仍可完成该回执。
## 改动与归属
| 层 | 改动 | 作用域 |
@@ -30,11 +34,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` 上。
@@ -42,10 +45,10 @@ 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 自查。
## 边界与待定事项
本次实现把 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 绕过准入。
@@ -26,7 +26,7 @@ Persist a permanent tenant/Harness-Session drain fence under the existing tenant
Fence probes use a nonlocking read after the placement guard, avoiding InnoDB gap locks that would block another tenant's fence insertion when no row exists. Execution admission discovers the immutable binding tenant outside the admission transaction; its first consistent read occurs after acquiring the guard, so REPEATABLE READ cannot hide a fence committed while admission was waiting.
Enumerate saved binding generations by tenant, Session isolation class, and isolation key, in bounded pages with byte-exact identities regardless of database collation. Mark them draining without authorizing current Workspace execution. Enumerate Runtime Sessions by exact binding/generation and release using saved records: provider release, activation=false acknowledgement, conditional original-holder release, then durable RELEASED. No acquire, installation, execution replay, or model call is part of close. Unknown execution or startup identity blocks completion. An unusable original worker blocks with an identity failure instead of entering generic lease recovery. A newer holder on shared storage is preserved.
Enumerate saved binding generations by tenant, Session isolation class, and isolation key, in bounded pages with byte-exact identities regardless of database collation. Mark them draining without authorizing current Workspace execution. Enumerate Runtime Sessions by exact binding/generation and release using saved records: provider release, activation=false acknowledgement, conditional original-holder release, then durable RELEASED. No acquire, installation, execution replay, or model call is part of close. Unknown execution or startup identity blocks completion. A LOST binding with no active Runtime Sessions or executions may retire through the same holder check and durable stop receipt; it remains LOST until the receipt commits RELEASED. A LOST binding with unsettled resources still requires recovery. An unusable original worker with an unreleased Session blocks with an identity failure instead of entering generic lease recovery. A newer holder on shared storage is preserved.
## Worker stop and completion
@@ -26,7 +26,7 @@ Harness prompt 和既有 Runtime 恢复准入路由在本地 close 开始后拒
栅栏查询在取得 placement 锁后进行普通读取,避免缺失行上的 InnoDB 间隙锁阻塞其他租户插入栅栏。Execution 准入在准入事务之外读取不可变的 binding tenant;事务的首次一致性读发生在取得锁之后,避免 REPEATABLE READ 隐藏等待期间已提交的栅栏。
按 tenant、Session isolation class 和 isolation key 分页枚举保存的 binding 代际;身份按字节精确匹配,不依赖数据库排序规则。标记 draining 时不重新授权当前 Workspace 执行。按精确 binding/generation 枚举 Runtime Sessions,使用保存的记录按顺序释放:provider release、activation=false 确认、条件释放原 holder、持久 RELEASED。close 不进行 acquire、安装、执行重放或模型调用。未知执行或启动身份阻止完成。原 worker 不可用时返回身份失败并阻塞,不进入通用租约恢复。共享存储上的新 holder 必须保留。
按 tenant、Session isolation class 和 isolation key 分页枚举保存的 binding 代际;身份按字节精确匹配,不依赖数据库排序规则。标记 draining 时不重新授权当前 Workspace 执行。按精确 binding/generation 枚举 Runtime Sessions,使用保存的记录按顺序释放:provider release、activation=false 确认、条件释放原 holder、持久 RELEASED。close 不进行 acquire、安装、执行重放或模型调用。未知执行或启动身份阻止完成。没有活跃 Runtime Session 或 execution 的 LOST binding,可以经过同样的 holder 检查与持久停机凭据完成退休;提交凭据并进入 RELEASED 前保持 LOST。有未结算资源的 LOST binding 仍需要恢复。原 worker 不可用且仍有未释放的 Session 时返回身份失败并阻塞,不进入通用租约恢复。共享存储上的新 holder 必须保留。
## Worker 停机与完成
@@ -98,8 +98,8 @@ Design: [English](../../../docs/design/2026-09-29-managed-tool-result-public-pro
- MySQL 8
Run the packaged CLI with `qwen serve --profile hosted-harness` as a separate
process. It supports durable no-tool Sessions and the opt-in initial Workspace
file Turn described in the G0 section below.
process. It supports durable no-tool Sessions and the opt-in Workspace file
Turns described in the G0 section below.
Install the two sibling libraries once when building this module outside a
Maven reactor:
@@ -190,9 +190,16 @@ 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`. When a rename failure is recorded, its `PENDING` command becomes `FAILED`
while retaining its receipt and request digest. The same
key retries the same content with the replay flag set; changed content or a
different Session conflicts. A successful concurrent request can still complete
the receipt, and a failing sibling cannot overwrite that completed outcome.
Retries do not re-append the original `requested` event. If the command store
is unavailable during cleanup, the original API failure is preserved and the
same key can resume its receipt when storage returns. Only an in-flight
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
@@ -348,8 +355,14 @@ creation with input, including replays, while empty bound creation remains
available. The directory mounted for a Workspace is trusted deployment data,
not a filesystem sandbox.
Later submit/cancel/lifecycle/cwd operations and broad Workspace capability
advertisement remain gated. Shell and in-flight recovery are separate slices.
Later Turns may be submitted by the Session's creator under the
same opt-in while they can still read and create in the Workspace (the
per-caller `workspaceTurns` capability flag reflects the caller's current
grants and the Workspace registry's `ACTIVE` state), and the creator may cancel
the Session's running Turns and rename the Session. Workspace close follows
its separate close capability and lifecycle admission. Archive, delete and
unarchive follow their separate retention capabilities after reliable Workspace
close. Cwd operations and broad Workspace capability advertisement remain gated. Shell and in-flight recovery are separate slices.
The existing `EmbeddedRuntimeBroker` is used through production configuration;
no direct store admission or test Broker replacement is needed.
@@ -477,7 +490,16 @@ 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;
in G0 above and to later Turns submitted by the Session's creator under the same
opt-in while they can still read and create in the Workspace (the per-caller
`workspaceTurns` capability flag reflects the caller's current grants 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 follows its separate close
capability and lifecycle admission. Archive, delete and unarchive follow their
separate retention capabilities after reliable Workspace close;
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.
@@ -559,8 +581,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:
@@ -83,8 +83,9 @@ public final class ApiModels {
@JsonProperty("session_delete") boolean sessionDelete) {
}
public record WebShellSessionCapabilities(boolean tasks, boolean artifacts, boolean actions, boolean sessionClose,
boolean sessionArchive, boolean sessionUnarchive, boolean sessionDelete) {
public record WebShellSessionCapabilities(boolean tasks, boolean artifacts, boolean actions,
boolean workspaceTurns, boolean sessionClose, boolean sessionArchive, boolean sessionUnarchive,
boolean sessionDelete) {
}
@JsonInclude(JsonInclude.Include.NON_NULL)
@@ -155,6 +155,9 @@ public class HarnessCoordinator {
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);
@@ -547,18 +550,22 @@ public class HarnessCoordinator {
}
SessionRecord session = store.requireSession(tenantId,
sessionId);
if (session.workspace() != null) {
// A bound Session's Turn is cancelled like any other once
// Workspace files are enabled: the Hosted Harness aborts the
// Turn and settles its Runtime calls through their original
// identities. Without the opt-in nothing may reach it.
if (session.workspace() != null
&& !harness.isWorkspaceFilesAvailable()) {
return;
}
Attachment attachment = harness.createOrLoad(
session.tenantId(), session.sessionId(),
session.harnessBootId() != null);
if (store.bindHarness(tenantId, sessionId,
turnId, owner, attachment.bootId())) {
// Reuse the admitted attachment: attaching would recheck grants
// needed for new work and could replace the running attachment.
if (session.harnessBootId() != null && store.bindHarness(tenantId,
sessionId, turnId, owner, session.harnessBootId())) {
harness.cancel(session.tenantId(), session.sessionId());
}
} catch (RuntimeException error) {
LOG.warn("Managed Turn cancellation will recover tenant={}"
LOG.warn("Managed Turn cancellation awaits lease renewal tenant={}"
+ " session={} turn={} failure={}",
tenantId, sessionId, turnId,
error.getClass().getSimpleName());
@@ -43,6 +43,8 @@ import com.alibaba.qwen.code.managedagent.store.StoreModels.SnapshotRecord;
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;
@@ -52,6 +54,8 @@ import java.util.Map;
import java.util.Set;
import java.util.function.BooleanSupplier;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
@@ -60,6 +64,7 @@ import com.alibaba.qwen.code.managedagent.store.ManagedActionStore;
@Service
public class ManagedAgentService {
private static final Logger LOG = LoggerFactory.getLogger(ManagedAgentService.class);
private static final String CREATE = "CREATE_SESSION";
private static final String SUBMIT = "SUBMIT_TURN";
private static final String CANCEL = "CANCEL_TURN";
@@ -217,7 +222,7 @@ public class ManagedAgentService {
String idempotencyKey, String sessionId,
List<InputBlock> blocks) {
validateIdempotencyKey(idempotencyKey);
requireLegacyWorkspace(tenantId, actorId, sessionId);
requireSubmitter(tenantId, actorId, sessionId);
requireHarness();
List<Map<String, Object>> input = input(blocks, true);
String requestDigest = digests.digest(Map.of(
@@ -245,7 +250,7 @@ public class ManagedAgentService {
public CommandAdmission cancelTurn(String tenantId, String actorId,
String idempotencyKey, String sessionId, String turnId) {
validateIdempotencyKey(idempotencyKey);
requireLegacyWorkspace(tenantId, actorId, sessionId);
requireSubmitter(tenantId, actorId, sessionId);
String requestDigest = digests.digest(Map.of(
"sessionId", sessionId, "turnId", turnId));
Admission replay = replay(tenantId, CANCEL, idempotencyKey,
@@ -275,7 +280,7 @@ public class ManagedAgentService {
String tenantId, String actorId, String idempotencyKey, String sessionId,
String title) {
validateIdempotencyKey(idempotencyKey);
requireLegacyWorkspace(tenantId, actorId, sessionId);
requireSubmitter(tenantId, actorId, sessionId);
String effectiveTitle = validRenameTitle(title);
String requestDigest = digests.digest(Map.of(
"sessionId", sessionId, "title", effectiveTitle));
@@ -283,22 +288,39 @@ public class ManagedAgentService {
RENAME, idempotencyKey, requestDigest, sessionId,
SessionMutationKind.RENAME);
if (!"COMPLETED".equals(command.status())) {
requireHarness();
SessionRecord session = store.requireSession(tenantId, sessionId);
HarnessConnector.Attachment attachment;
try {
attachment = harness.createOrLoad(tenantId, sessionId,
requireHarness();
SessionRecord session = store.requireSession(tenantId, sessionId);
HarnessConnector.Attachment attachment = harness.createOrLoad(tenantId, sessionId,
session.harnessBootId() != null);
harness.rename(tenantId, sessionId, effectiveTitle);
session = store.completeSessionMutation(tenantId, RENAME,
idempotencyKey, sessionId, SessionMutationKind.RENAME,
effectiveTitle, attachment.bootId());
return new SessionMutationResult<>(publicSession(session),
command.replayed());
} catch (RuntimeException error) {
try {
store.abandonSessionMutation(tenantId, RENAME,
idempotencyKey, sessionId);
} catch (RuntimeException cleanupError) {
LOG.warn("Failed to retire rename tenant={} session={}",
tenantId, sessionId, cleanupError);
}
if (error instanceof ApiException failure) {
throw failure;
}
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.");
}
session = store.completeSessionMutation(tenantId, RENAME,
idempotencyKey, sessionId, SessionMutationKind.RENAME,
effectiveTitle, attachment.bootId());
return new SessionMutationResult<>(publicSession(session),
command.replayed());
}
return new SessionMutationResult<>(getPublicSession(tenantId,
sessionId), true);
@@ -315,7 +337,7 @@ public class ManagedAgentService {
public SessionMutationResult<WebShellSession> unarchiveWebShellSession(
String tenantId, String actorId, String idempotencyKey, String sessionId) {
var result = unarchive(tenantId, actorId, idempotencyKey, sessionId);
return new SessionMutationResult<>(webShellSession(result.session()), result.replayed());
return new SessionMutationResult<>(webShellSession(result.session(), actorId), result.replayed());
}
private StoreModels.SessionMutation unarchive(String tenantId, String actorId,
@@ -357,7 +379,7 @@ public class ManagedAgentService {
public WebShellSession getWebShellSession(String tenantId, String actorId,
String sessionId) {
return webShellSession(requireReadableSession(tenantId, actorId,
sessionId));
sessionId), actorId);
}
public PublicList<PublicSession> listPublicSessions(String tenantId,
@@ -382,8 +404,8 @@ public class ManagedAgentService {
decoded == null ? null : decoded.updatedAt(),
decoded == null ? null : decoded.sessionId(), limit);
return new WebShellPage<>(page.sessions().stream()
.map(this::webShellSession).toList(), nextCursor(page),
page.hasMore());
.map(session -> webShellSession(session, actorId)).toList(),
nextCursor(page), page.hasMore());
}
/**
@@ -549,7 +571,8 @@ public class ManagedAgentService {
publicWorkspace(session));
}
private WebShellSession webShellSession(SessionRecord session) {
private WebShellSession webShellSession(SessionRecord session,
String actorId) {
TurnRecord latestTurn = store.findLatestTurn(session.tenantId(),
session.sessionId()).orElse(null);
EventRecord environmentEvent = store.findLatestEnvironmentEvent(
@@ -568,7 +591,8 @@ public class ManagedAgentService {
webShellWorkspace(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), supportsClose(session),
new WebShellSessionCapabilities(true, hasArtifacts(session), hasActions(session),
maySubmitWorkspaceTurn(session, actorId), supportsClose(session),
retention, retention, retention));
}
@@ -716,11 +740,59 @@ public class ManagedAgentService {
"sessionId", sessionId, "operation", operation));
}
void requireLegacyWorkspace(String tenantId, String actorId,
// 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.
private void requireSubmitter(String tenantId, String actorId,
String sessionId) {
SessionRecord session = store.requireSession(tenantId, sessionId);
if (!maySubmitWorkspaceTurn(session, actorId)) {
requireLegacyWorkspace(session, actorId);
}
}
private boolean maySubmitWorkspaceTurn(SessionRecord session,
String actorId) {
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;
}
// 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_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());
return summary != null && summary.canCreateSession();
}
void requireLegacyWorkspace(String tenantId, String actorId,
String 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.");
@@ -59,6 +59,16 @@ public interface AgentStateStore {
String idempotencyKey, String sessionId,
SessionMutationKind kind, String title, String harnessBootId);
/**
* Retires the command row of a Session mutation the Harness refused
* before completion, so the refusal does not leave the Session's
* later lifecycle changes blocked by a {@code PENDING} row nothing
* completes. The receipt and digest survive for same-content retries
* and concurrent completion; completed outcomes remain replayable.
*/
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;
@@ -321,6 +321,12 @@ public class ManagedAgentStore implements AgentStateStore {
String turnId) {
}
// A bound Session's creator may rename it under the Workspace-files
// opt-in (the service checks the creator); unarchive stays gated.
private boolean boundRenameAllowed(SessionMutationKind kind) {
return kind == SessionMutationKind.RENAME && workspaceFilesEnabled;
}
private static ApiException workspaceExecutionUnavailable() {
return new ApiException(HttpStatus.CONFLICT,
"workspace_unavailable",
@@ -427,7 +433,9 @@ public class ManagedAgentStore implements AgentStateStore {
String idempotencyKey, String requestDigest, String sessionId,
List<Map<String, Object>> input, String payloadDigest) {
SessionRecord session = requireSessionForUpdate(tenantId, sessionId);
if (session.workspace() != null) {
// A bound Session's later Turn needs the same deployment opt-in as
// its initial one; the service admits only the Session's creator.
if (session.workspace() != null && !workspaceFilesEnabled) {
throw workspaceExecutionUnavailable();
}
Optional<CommandRecord> existing = findCommand(tenantId, operation,
@@ -459,7 +467,8 @@ public class ManagedAgentStore implements AgentStateStore {
public Admission insertCancelCommand(String tenantId, String operation,
String idempotencyKey, String requestDigest, String sessionId,
String turnId) {
if (requireSessionForUpdate(tenantId, sessionId).workspace() != null) {
if (requireSessionForUpdate(tenantId, sessionId).workspace() != null
&& !workspaceFilesEnabled) {
throw workspaceExecutionUnavailable();
}
TurnRecord turn = requireTurn(tenantId, sessionId, turnId);
@@ -490,7 +499,7 @@ public class ManagedAgentStore implements AgentStateStore {
String operation, String idempotencyKey, String requestDigest,
String sessionId, SessionMutationKind kind) {
SessionRecord session = requireSessionForUpdate(tenantId, sessionId);
if (session.workspace() != null) {
if (session.workspace() != null && !boundRenameAllowed(kind)) {
throw workspaceExecutionUnavailable();
}
Optional<CommandRecord> existing = findCommand(tenantId, operation,
@@ -503,6 +512,15 @@ public class ManagedAgentStore implements AgentStateStore {
"idempotency_conflict",
"The idempotency key was reused with different content.");
}
if ("FAILED".equals(command.status())) {
requireNoOpenOperation(tenantId, sessionId);
validateMutationStatus(session, kind);
jdbc.update("UPDATE managed_agent_command SET command_status ="
+ " 'PENDING', updated_at = ? WHERE tenant_id = ?"
+ " AND operation = ? AND idempotency_key = ?",
clock.millis(), tenantId, operation, idempotencyKey);
return new SessionMutationCommand(sessionId, "PENDING", true);
}
return new SessionMutationCommand(sessionId, command.status(),
true);
}
@@ -511,10 +529,15 @@ public class ManagedAgentStore implements AgentStateStore {
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);
// Older retired commands may have left their requested event behind.
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);
}
@@ -523,7 +546,7 @@ public class ManagedAgentStore implements AgentStateStore {
String operation, String idempotencyKey, String sessionId,
SessionMutationKind kind, String title, String harnessBootId) {
SessionRecord session = requireSessionForUpdate(tenantId, sessionId);
if (session.workspace() != null) {
if (session.workspace() != null && !boundRenameAllowed(kind)) {
throw workspaceExecutionUnavailable();
}
CommandRecord command = findCommand(tenantId, operation,
@@ -538,7 +561,8 @@ public class ManagedAgentStore implements AgentStateStore {
if ("COMPLETED".equals(command.status())) {
return session;
}
if (!"PENDING".equals(command.status())) {
if (!"PENDING".equals(command.status())
&& !"FAILED".equals(command.status())) {
throw new IllegalStateException(
"Session mutation command has an unknown status");
}
@@ -574,6 +598,19 @@ public class ManagedAgentStore implements AgentStateStore {
return requireSessionForUpdate(tenantId, sessionId);
}
@Override
@Transactional
public void abandonSessionMutation(String tenantId, String operation,
String idempotencyKey, String sessionId) {
// Keep the digest and receipt for replay and concurrent completion.
// A completed outcome must never be overwritten by a failing sibling.
jdbc.update("UPDATE managed_agent_command SET command_status = 'FAILED',"
+ " updated_at = ? WHERE tenant_id = ?"
+ " AND operation = ? AND idempotency_key = ?"
+ " AND session_id = ? AND command_status = 'PENDING'",
clock.millis(), tenantId, operation, idempotencyKey, sessionId);
}
@Override
@Transactional
public OperationAdmission beginOperation(String tenantId,
@@ -26,13 +26,31 @@ public class ManagedWorkspaceRegistry {
this.jdbc = jdbc;
}
public boolean isSessionCreator(String tenantId, String sessionId, String actorId) {
if (actorId == null) {
/**
* Whether the actor created this Workspace-bound Session. Its later Turns
* run under the creator's grants, so only the creator may submit them.
*/
public boolean createdSession(String tenantId, String actorId,
String sessionId) {
if (actorId == null || actorId.isEmpty()) {
return false;
}
return Integer.valueOf(1).equals(jdbc.queryForObject("SELECT COUNT(*)"
+ " FROM managed_workspace_create_command WHERE tenant_id = ? AND session_id = ? AND actor_id = ?",
Integer.class, tenantId, sessionId, actorKey(tenantId, actorId)));
byte[] key;
try {
key = actorKey(tenantId, actorId);
} catch (IllegalArgumentException error) {
return false;
}
return !jdbc.queryForList("SELECT 1 FROM managed_workspace_create_command"
+ " WHERE tenant_id = ? AND session_id = ?"
+ " AND CAST(CONCAT(tenant_id, '!') AS BINARY(513))"
+ " = CAST(CONCAT(?, '!') AS BINARY(513))"
+ " AND actor_id = ?",
Integer.class, tenantId, sessionId, tenantId, key).isEmpty();
}
public boolean isSessionCreator(String tenantId, String sessionId, String actorId) {
return createdSession(tenantId, actorId, sessionId);
}
public boolean canRead(String tenantId, String actorId,
@@ -3,7 +3,7 @@
"info": {
"title": "Qwen Managed Agent Public and WebShell API",
"version": "1.29.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. 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 adds O3 durable tool-result projection, artifact metadata, immutable range/streaming content and WebShell result reads. Artifact capability requires a configured O3 reader and a Workspace-bound Session; execution admission remains independent. v1.29 implements the AgentDefinition routes (Stage D8a): createLine truncated
"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. 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 adds O3 durable tool-result projection, artifact metadata, immutable range/streaming content and WebShell result reads. Artifact capability requires a configured O3 reader and a Workspace-bound Session; execution admission remains independent. v1.28 admits later Turns of a Workspace-bound Session for the SLine truncated
},
"servers": [
{
@@ -184,7 +184,7 @@
"tags": ["Public Sessions"],
"operationId": "createSession",
"x-qwen-implementation-status": "implemented",
"description": "Workspace selection is persisted with the original actor-scoped creation receipt. With the deployment's G0 file admission opt-in, qwen-code Sessions in an authorized, preregistered Workspace may include initial input under the fixed preapproved file-tool profile. Public callers cannot select the profile. Otherwise Workspace creation must have empty input. 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, while the creator currently holds Workspace read and create grants on a registry row whose state is ACTIVE and the Session is an active, undeleted qwen-code Session on the frozen execution profile; Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. This surface has no per-caller capability flag for later Turns, so callers must handle 409 workspace_unavailable. Check replay before resolving defaults; a durable binding is not proof of Runtime readiness.",
"parameters": [
{
"$ref": "#/components/parameters/IdempotencyKey"
@@ -1129,7 +1129,7 @@
"tags": ["WebShell"],
"operationId": "webShellCreateSession",
"x-qwen-implementation-status": "implemented",
"description": "Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. 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, while the creator currently holds Workspace read and create grants on a registry row whose state is ACTIVE and the Session is an active, undeleted qwen-code Session on the frozen execution profile; Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. 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": {
@@ -4741,6 +4741,11 @@
"default": false,
"x-qwen-implementation-status": "planned"
},
"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 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"
},
@@ -5177,7 +5182,7 @@
},
"workspace_binding": {
"type": "boolean",
"description": "Supports authorized Workspace discovery, Session creation, and saved binding read-back. This capability does not advertise execution readiness. Deployments may separately opt in to an initial Workspace Read/Write/Edit Turn at creation; later submit, cancel and lifecycle operations remain gated."
"description": "Supports authorized Workspace discovery, Session creation, and saved binding read-back. This capability does not advertise execution readiness. Deployments may separately opt in to an initial Workspace Read/Write/Edit Turn at creation; the Session creator may submit and cancel later Turns under the same opt-in, while close follows its separate capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close."
}
},
"x-qwen-implementation-status": "partial"
@@ -5410,7 +5415,7 @@
},
"workspaceBinding": {
"type": "boolean",
"description": "Supports authorized Workspace discovery, Session creation, and saved binding read-back. This capability does not advertise execution readiness. Deployments may separately opt in to an initial Workspace Read/Write/Edit Turn at creation; later submit, cancel and lifecycle operations remain gated."
"description": "Supports authorized Workspace discovery, Session creation, and saved binding read-back. This capability does not advertise execution readiness. Deployments may separately opt in to an initial Workspace Read/Write/Edit Turn at creation; the Session creator may submit and cancel later Turns under the same opt-in, while close follows its separate capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close."
}
},
"x-qwen-implementation-status": "partial"
@@ -30,6 +30,7 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterEach;
@@ -39,6 +40,8 @@ import org.junit.jupiter.api.condition.EnabledOnOs;
import org.junit.jupiter.api.condition.OS;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.api.io.CleanupMode;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext;
@@ -53,6 +56,8 @@ class HostedPublicWorkspaceIT {
private final String tenant = "g0-" + UUID.randomUUID();
private final List<JsonNode> modelRequests = new CopyOnWriteArrayList<>();
private final AtomicReference<Throwable> modelFailure = new AtomicReference<>();
// Holds the model reply to a G0_CANCEL prompt so the test can cancel a running Turn.
private volatile CountDownLatch heldReply = new CountDownLatch(1);
@TempDir(cleanup = CleanupMode.ON_SUCCESS)
private Path temporary;
private ServletWebServerApplicationContext spring;
@@ -77,13 +82,14 @@ class HostedPublicWorkspaceIT {
void ownerAnswersHostedApprovalsThroughBothSurfaces() throws Exception {
approvals = true;
runFiles();
assertThat(answered).hasSize(4);
assertThat(answered).hasSize(8);
}
@Test
@ParameterizedTest
@ValueSource(booleans = {false, true})
@EnabledOnOs(OS.LINUX)
@Timeout(150)
void durableCloseStopsOriginalWorkersAndRetainsHistoryAndFiles() throws Exception {
void durableCloseStopsOriginalWorkersAndRetainsHistoryAndFiles(boolean crash) throws Exception {
durableClose = true;
runFiles();
List<String> sessions = jdbc.queryForList("SELECT session_id FROM managed_agent_session WHERE tenant_id = ?"
@@ -98,6 +104,23 @@ class HostedPublicWorkspaceIT {
long pid = json.readTree(Files.readString(registration)).path("pid").asLong();
var worker = ProcessHandle.of(pid).orElseThrow();
assertThat(worker.isAlive()).isTrue();
if (crash) {
worker.destroyForcibly();
worker.onExit().get(5, TimeUnit.SECONDS);
if (index == 1) {
jdbc.update("UPDATE managed_workspace_registry SET config_ref = ? WHERE tenant_id = ?"
+ " AND workspace_id = ?", WorkspaceExecutionProfile.CONFIG_REF, tenant, "workspace-" + index);
String failedTurn = request("POST", "/v1/agents/sessions/" + session + "/events",
Map.of("type", "agent.session.input.message", "input",
List.of(Map.of("type", "input_text", "text", "G0_AGAIN"))),
"after-crash", "actor", 202).path("turn_id").asText();
await().atMost(Duration.ofSeconds(35)).untilAsserted(() -> assertThat(jdbc.queryForObject(
"SELECT status FROM managed_agent_turn WHERE session_id = ? AND turn_id = ?",
String.class, session, failedTurn)).isEqualTo("FAILED"));
}
assertThat(jdbc.queryForObject("SELECT binding_state FROM qwen_runtime_binding WHERE binding_id = ?",
String.class, binding)).isEqualTo(index == 0 ? "READY" : "LOST");
}
var retained = jdbc.queryForList("SELECT resource_id, sha256 FROM qwen_managed_session_resource"
+ " WHERE tenant_id = ? AND session_id = ? ORDER BY resource_id", tenant, session);
assertThat(retained).isNotEmpty();
@@ -129,6 +152,19 @@ class HostedPublicWorkspaceIT {
.resolve("child/proof.txt"))).isEqualTo("after");
assertThat(request("POST", route, body, "close", "actor", 202)
.path(webShell ? "operationId" : "id").asText()).isEqualTo(operation);
if (crash) {
jdbc.update("UPDATE managed_workspace_registry SET config_ref = ? WHERE tenant_id = ?"
+ " AND workspace_id = ?", WorkspaceExecutionProfile.CONFIG_REF, tenant, "workspace-" + index);
String nextSession = request("POST", "/v1/agents/sessions",
Map.of("agent_id", "qwen-code", "input", List.of(Map.of("type", "input_text", "text", "G0_FILES")),
"workspace", Map.of("workspace_id", "workspace-" + index, "cwd_relative", "child")),
"after-close-" + index, "actor", 202).path("id").asText();
await().atMost(Duration.ofSeconds(35)).untilAsserted(() -> assertThat(jdbc.queryForObject(
"SELECT status FROM managed_agent_turn WHERE session_id = ?", String.class, nextSession))
.isEqualTo("COMPLETED"));
assertThat(Files.readString(temporary.resolve(index == 0 ? "workspace-a" : "workspace-b")
.resolve("child/proof.txt"))).isEqualTo("after");
}
}
}
@@ -224,10 +260,120 @@ class HostedPublicWorkspaceIT {
assertThat(request("POST", route, changed, workspace, "actor", 409).path("error").path("code").asText())
.isEqualTo("idempotency_conflict");
request("GET", "/v1/agents/sessions/" + session, null, null, "other", 404);
request("POST", "/v1/agents/sessions/" + session + "/events",
Map.of("type", "agent.session.input.message", "input", List.of(input)), "later", "actor", 409);
// A later Turn runs under the creator's grants: another actor who can read the
// Session keeps the refusal, and the creator's second Turn runs the file tools again.
Map<String, Object> later = Map.of("type", "agent.session.input.message", "input",
List.of(Map.of("type", "input_text", "text", "G0_AGAIN")));
assertThat(request("POST", "/v1/agents/sessions/" + session + "/events", later,
"reader-later-" + workspace, "reader", 409).path("error").path("code").asText())
.isEqualTo("workspace_unavailable");
// WebShell advertises the same rule, per caller.
for (String caller : List.of("actor", "reader")) {
assertThat(request("POST", "/api/agent/web-shell/v1/sessions/get", Map.of("sessionId", session),
null, caller, 200).path("capabilities").path("workspaceTurns").asBoolean())
.as(caller).isEqualTo("actor".equals(caller));
}
// 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();
await().atMost(Duration.ofSeconds(35)).failFast(() -> {
String status = jdbc.queryForObject("SELECT status FROM managed_agent_turn"
+ " WHERE session_id = ? AND turn_id = ?", String.class, session, laterTurn);
if ("FAILED".equals(status)) {
throw new AssertionError("Later Turn failed. Harness: "
+ Files.readString(temporary.resolve("harness.log")));
}
}).untilAsserted(() -> {
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))
.isEqualTo("COMPLETED");
});
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM qwen_tool_execution WHERE harness_session_id = ?",
Long.class, session)).isEqualTo(executions * 2);
assertThat(modelRequests).hasSize(requests + 4);
assertThat(Files.readString(roots.get(index).resolve("child/proof.txt"))).isEqualTo("after");
assertThat(decoy.resolve("proof.txt")).doesNotExist();
// 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.
int beforeCancel = modelRequests.size();
Map<String, Object> hold = Map.of("type", "agent.session.input.message", "input",
List.of(Map.of("type", "input_text", "text", "G0_CANCEL")));
String heldTurn = request("POST", "/v1/agents/sessions/" + session + "/events", hold,
"hold-" + workspace, "actor", 202).path("turn_id").asText();
await().atMost(Duration.ofSeconds(35)).until(() -> modelRequests.size() > beforeCancel);
Map<String, Object> cancel = Map.of("type", "agent.session.cancel", "turn_id", heldTurn);
assertThat(request("POST", "/v1/agents/sessions/" + session + "/events", cancel,
"reader-cancel-" + workspace, "reader", 409).path("error").path("code").asText())
.isEqualTo("workspace_unavailable");
request("POST", "/v1/agents/sessions/" + session + "/events", cancel, "cancel-" + workspace,
"actor", 202);
await().atMost(Duration.ofSeconds(35)).untilAsserted(() -> assertThat(jdbc.queryForObject(
"SELECT status FROM managed_agent_turn WHERE session_id = ? AND turn_id = ?",
String.class, session, heldTurn)).isEqualTo("CANCELLED"));
heldReply.countDown();
heldReply = new CountDownLatch(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM qwen_tool_execution WHERE harness_session_id = ?",
Long.class, session)).isEqualTo(executions * 2);
// Only the creator may rename the bound Session.
Map<String, Object> rename = Map.of("title", "Renamed " + workspace);
assertThat(request("PATCH", "/v1/agents/sessions/" + session, rename, "reader-rename-" + workspace,
"reader", 409).path("error").path("code").asText()).isEqualTo("workspace_unavailable");
assertThat(request("PATCH", "/v1/agents/sessions/" + session, rename, "rename-" + workspace,
"actor", 200).path("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.
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(8);
assertThat(modelRequests).hasSize(approvals ? 16 : 18);
assertThat(modelFailure.get()).isNull();
Map<String, Object> denied = Map.of("agent_id", "qwen-code", "workspace", Map.of("workspace_id", "workspace-0"),
"input", List.of(Map.of("type", "input_text", "text", "G0_FILES")));
@@ -260,7 +406,7 @@ class HostedPublicWorkspaceIT {
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(8);
assertThat(modelRequests).hasSize(approvals ? 16 : 18);
}
private void startSpring(Path cli, List<Path> roots, int harnessPort, int brokerPort) {
@@ -328,8 +474,11 @@ class HostedPublicWorkspaceIT {
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 {
@@ -411,10 +560,27 @@ class HostedPublicWorkspaceIT {
List<String> tools = new ArrayList<>();
body.path("tools").forEach(tool -> tools.add(tool.path("function").path("name").asText()));
assertThat(tools).containsExactlyInAnyOrder("read_file", "write_file", "edit");
// Count only this Turn's tool results, after the latest fixture prompt, so a later
// Turn in the same Session runs the same write, edit and read sequence. Other user
// messages the Harness may add do not restart the count.
List<JsonNode> results = new ArrayList<>();
AtomicReference<String> prompt = new AtomicReference<>("");
body.path("messages").forEach(message -> {
if ("tool".equals(message.path("role").asText())) results.add(message);
String role = message.path("role").asText();
if ("user".equals(role) && message.path("content").toString().contains("G0_")) {
results.clear();
String content = message.path("content").toString();
prompt.set(content.substring(content.lastIndexOf("G0_")));
} else if ("tool".equals(role)) {
results.add(message);
}
});
if (prompt.get().contains("G0_CANCEL")) {
// Reply with nothing until the test has cancelled the Turn; the Harness has
// aborted this request by then, so there is no response to write.
heldReply.await(60, TimeUnit.SECONDS);
return;
}
int step = results.size();
if (step == 3) assertThat(results.get(2).toString()).contains("after");
var chunk = json.createObjectNode().put("id", "g0").put("object", "chat.completion.chunk")
@@ -1356,7 +1356,7 @@ class ManagedAgentApiContractTest {
.isEqualTo(session.get("last_event_id").asLong());
assertThat(other.get("capabilities"))
.isEqualTo(json("""
{"tasks":true,"artifacts":false,"actions":false,"sessionClose":true,
{"tasks":true,"artifacts":false,"actions":false,"workspaceTurns":false,"sessionClose":true,
"sessionArchive":true,"sessionUnarchive":true,"sessionDelete":true}
"""));
}
@@ -640,7 +640,7 @@ class ManagedAgentServerIntegrationTest {
}
@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 @@ class ManagedAgentServerIntegrationTest {
.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 retained failed receipt replays the same content and retries
// the Harness mutation without duplicating the requested event.
mvc.perform(patch("/v1/agents/sessions/{id}", sessionId)
.header(TenantContextFilter.HEADER, tenant)
.header("Idempotency-Key", "rename-retry")
@@ -685,7 +687,7 @@ class ManagedAgentServerIntegrationTest {
.getResponse().getContentAsString()).get("data");
assertThat(events).filteredOn(event -> "session.updated".equals(
event.get("type").asText()))
.hasSize(1);
.hasSize(2);
}
@Test
@@ -1,11 +1,26 @@
package com.alibaba.qwen.code.managedagent;
import org.springframework.http.HttpStatus;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.params.ParameterizedTest;
import com.alibaba.qwen.code.managedagent.store.AgentStateStore;
import com.alibaba.qwen.code.managedagent.service.RequestDigests;
import com.alibaba.qwen.code.managedagent.service.ManagedAgentService;
import com.alibaba.qwen.code.managedagent.api.ApiException;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.AdditionalAnswers.delegatesTo;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -19,6 +34,7 @@ import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.Acquir
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;
@@ -380,11 +396,8 @@ class ManagedSessionLifecycleTest {
.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")
@@ -416,6 +429,177 @@ class ManagedSessionLifecycleTest {
awaitCompleted(tenant, sessionId, closeId);
}
@Test
void unavailableHarnessDoesNotBlockLaterRenameOrCompletedReplay()
throws Exception {
String tenant = tenant();
String sessionId = attachedSession(tenant);
harness.setAvailable(false);
try {
lifecycle(patch("/v1/agents/sessions/{id}", sessionId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"offline\"}"), tenant, "offline")
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.error.code")
.value("hosted_harness_disabled"));
} finally {
harness.setAvailable(true);
}
lifecycle(patch("/v1/agents/sessions/{id}", sessionId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"online\"}"), tenant, "online")
.andExpect(status().isOk())
.andExpect(jsonPath("$.metadata.title").value("online"));
harness.setAvailable(false);
try {
lifecycle(patch("/v1/agents/sessions/{id}", sessionId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"online\"}"), tenant, "online")
.andExpect(status().isOk())
.andExpect(header().string("X-Qwen-Idempotent-Replay", "true"));
} finally {
harness.setAvailable(true);
}
}
@Test
void failedRenameReceiptKeepsItsDigestAndConcurrentCompletion() throws Exception {
String tenant = tenant();
String sessionId = attachedSession(tenant);
String otherId = attachedSession(tenant);
String digest = new RequestDigests().digest(
java.util.Map.of("sessionId", sessionId, "title", "renamed"));
assertThat(store.beginSessionMutation(tenant, "RENAME_SESSION", "key",
digest, sessionId, SessionMutationKind.RENAME).replayed()).isFalse();
assertThat(store.beginSessionMutation(tenant, "RENAME_SESSION", "key",
digest, sessionId, SessionMutationKind.RENAME).replayed()).isTrue();
store.abandonSessionMutation(tenant, "RENAME_SESSION", "key", sessionId);
for (String target : List.of(sessionId, otherId)) {
assertThatThrownBy(() -> store.beginSessionMutation(tenant,
"RENAME_SESSION", "key", "other-digest", target,
SessionMutationKind.RENAME))
.isInstanceOfSatisfying(ApiException.class, error ->
assertThat(error.getCode()).isEqualTo("idempotency_conflict"));
}
harness.rename(tenant, sessionId, "renamed");
store.completeSessionMutation(tenant, "RENAME_SESSION", "key", sessionId,
SessionMutationKind.RENAME, "renamed",
store.requireSession(tenant, sessionId).harnessBootId());
store.abandonSessionMutation(tenant, "RENAME_SESSION", "key", sessionId);
lifecycle(patch("/v1/agents/sessions/{id}", sessionId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"renamed\"}"), tenant, "key")
.andExpect(status().isOk())
.andExpect(jsonPath("$.metadata.title").value("renamed"))
.andExpect(header().string("X-Qwen-Idempotent-Replay", "true"));
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_event"
+ " WHERE tenant_id = ? AND session_id = ?"
+ " AND event_type = 'session.updated'",
Integer.class, tenant, sessionId)).isEqualTo(1);
}
@Test
void retryingFailedRenameAgainBlocksOtherLifecycleWork() throws Exception {
String tenant = tenant();
String sessionId = attachedSession(tenant);
store.beginSessionMutation(tenant, "RENAME_SESSION", "key", "digest",
sessionId, SessionMutationKind.RENAME);
store.abandonSessionMutation(tenant, "RENAME_SESSION", "key", sessionId);
assertThat(store.beginSessionMutation(tenant, "RENAME_SESSION", "key",
"digest", sessionId, SessionMutationKind.RENAME).replayed()).isTrue();
assertThatThrownBy(() -> store.beginSessionMutation(tenant,
"RENAME_SESSION", "other", "other-digest", sessionId,
SessionMutationKind.RENAME))
.isInstanceOfSatisfying(ApiException.class, error ->
assertThat(error.getCode()).isEqualTo("session_operation_active"));
}
@ParameterizedTest
@ValueSource(strings = {"lookup", "completion", "cleanup"})
void renameStoreFailuresPreserveTheOriginalErrorAndAllowRetry(String phase)
throws Exception {
String tenant = tenant();
String sessionId = attachedSession(tenant);
AgentStateStore faulted = mock(AgentStateStore.class, delegatesTo(store));
if ("lookup".equals(phase)) {
AtomicInteger reads = new AtomicInteger();
doAnswer(call -> {
if (reads.incrementAndGet() == 2) {
throw new IllegalStateException("lookup unavailable");
}
return store.requireSession(tenant, sessionId);
}).when(faulted).requireSession(tenant, sessionId);
} else if ("completion".equals(phase)) {
doThrow(new IllegalStateException("completion unavailable"))
.when(faulted).completeSessionMutation(anyString(), anyString(),
anyString(), anyString(), any(), anyString(), anyString());
} else {
doThrow(new IllegalStateException("cleanup unavailable"))
.when(faulted).abandonSessionMutation(tenant, "RENAME_SESSION",
"key", sessionId);
}
ManagedAgentService subject = new ManagedAgentService(faulted,
new RequestDigests(), null, harness, null);
harness.setAvailable(!"cleanup".equals(phase));
try {
assertThatThrownBy(() -> subject.renameSession(tenant, null, "key",
sessionId, "renamed"))
.isInstanceOfSatisfying(ApiException.class, error -> {
assertThat(error.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(error.getCode()).isEqualTo("cleanup".equals(phase)
? "hosted_harness_disabled" : "hosted_harness_unavailable");
});
} finally {
harness.setAvailable(true);
}
if (!"cleanup".equals(phase)) {
assertThat(jdbc.queryForObject("SELECT command_status FROM"
+ " managed_agent_command WHERE tenant_id = ?"
+ " AND operation = 'RENAME_SESSION' AND idempotency_key = 'key'",
String.class, tenant)).isEqualTo("FAILED");
}
lifecycle(patch("/v1/agents/sessions/{id}", sessionId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"renamed\"}"), tenant, "key")
.andExpect(status().isOk())
.andExpect(jsonPath("$.metadata.title").value("renamed"))
.andExpect(header().string("X-Qwen-Idempotent-Replay", "true"));
}
/**
* 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 {
@@ -3,21 +3,28 @@ package com.alibaba.qwen.code.managedagent;
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;
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;
@@ -531,6 +538,318 @@ class ManagedWorkspaceAdmissionTest {
Integer.class, tenant)).isZero();
}
@Test
void enabledStoreAdmitsALaterTurnForTheBoundSessionCreator() {
String tenant = "tenant-" + UUID.randomUUID();
register(tenant, "ws-a", "storage-a");
grant(tenant, "ws-a", "actor-a", true);
grant(tenant, "ws-a", "actor-b", true);
String digest = "sha256:" + "a".repeat(64);
String sessionId = store.insertWorkspaceSessionCommand(tenant,
"actor-a", "create", digest, "qwen-code", null, null,
List.of(), null, new WorkspaceSelection("ws-a", "."))
.sessionId();
List<Map<String, Object>> input = List.of(
Map.of("type", "text", "text", "again"));
// The creator lookup gates the service; other readers are not creators.
assertThat(registry.createdSession(tenant, "actor-a", sessionId))
.isTrue();
assertThat(registry.createdSession(tenant, "actor-b", sessionId))
.isFalse();
assertThat(registry.createdSession("tenant-" + UUID.randomUUID(),
"actor-a", sessionId)).isFalse();
// 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);
ManagedAgentStore gated = new ManagedAgentStore(jdbc, mapper,
Clock.systemUTC(), ignored -> {
}, registry, enabled);
TransactionTemplate transaction = new TransactionTemplate(
transactionManager);
var admission = transaction.execute(status ->
gated.insertTurnCommand(tenant, "SUBMIT", "later", digest,
sessionId, input, digest));
assertThat(admission.sessionId()).isEqualTo(sessionId);
assertThat(admission.turnId()).isNotBlank();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM"
+ " managed_agent_turn WHERE tenant_id = ?"
+ " AND session_id = ?",
Integer.class, tenant, sessionId)).isEqualTo(1);
// 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,
SessionMutationKind.UNARCHIVE)))
.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();
ManagedAgentService disabled = new ManagedAgentService(store,
new RequestDigests(), null, new UnavailableHarnessConnector(), registry);
assertThat(disabled.getWebShellSession(tenant, "actor-a", controlId)
.capabilities().workspaceTurns()).isFalse();
}
@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 -> gated.beginSessionMutation(
tenant, "RENAME_SESSION", "rename-blocker", digest, sessionId,
SessionMutationKind.RENAME));
transaction.executeWithoutResult(status ->
assertThatThrownBy(() -> service.renameSession(tenant,
"actor-a", "rename-1", sessionId, "first"))
.isInstanceOfSatisfying(ApiException.class, error ->
assertThat(error.getCode()).isEqualTo("session_operation_active")));
assertThat(jdbc.queryForObject("SELECT command_status FROM managed_agent_command"
+ " WHERE tenant_id = ? AND operation = 'RENAME_SESSION' AND idempotency_key = 'rename-1'",
String.class, tenant)).isEqualTo("FAILED");
transaction.executeWithoutResult(status -> gated.completeSessionMutation(
tenant, "RENAME_SESSION", "rename-blocker", sessionId,
SessionMutationKind.RENAME, "intermediate", "boot"));
// The freed key stays re-usable: a same-key retry re-attempts the
// mutation instead of colliding on the requested event the refused
// attempt already published.
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"
+ " managed_agent_session WHERE tenant_id = ?"
+ " AND session_id = ?", String.class, tenant,
sessionId)).isEqualTo("second");
}
@Test
void enabledCreationRefusesPolicyDriftAndAnotherTenantsMount() {
String tenant = "tenant-" + UUID.randomUUID();
@@ -631,7 +950,12 @@ class ManagedWorkspaceAdmissionTest {
.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 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")
.header(TenantContextFilter.HEADER, tenant)
.principal(actor(tenant, "actor-a"))
@@ -164,7 +164,9 @@ class WorkspaceSessionCloseMySqlIT {
assertThrows(TimeoutException.class, () -> responses.get().get(100, TimeUnit.MILLISECONDS));
return first.beginWorkspaceClose(tenant, session, OWNER, ACTOR_DIGEST, "close", "digest", true);
});
assertCode(turns.get().get(5, TimeUnit.SECONDS), "workspace_unavailable");
// With Workspace files enabled, a bound Session admits later Turns (#13112), so
// the queued Turn reaches the Session status and sees the committed close.
assertCode(turns.get().get(5, TimeUnit.SECONDS), "session_not_active");
assertCode(responses.get().get(5, TimeUnit.SECONDS), "session_inactive");
}
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_turn WHERE tenant_id = ?", Integer.class, tenant)).isZero();
@@ -1,5 +1,15 @@
package com.alibaba.qwen.code.managedagent.service;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.Executors;
import java.util.concurrent.CountDownLatch;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.after;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
@@ -210,10 +220,135 @@ class HarnessCoordinatorTest {
}
@Test
void boundCancellationNeverCallsTheLegacyHarness() {
AgentStateStore store = mock(AgentStateStore.class);
void boundCancellationWaitsForTheWorkspaceOptIn() {
AgentStateStore store = boundCancellingStore();
HarnessConnector harness = mock(HarnessConnector.class);
RuntimeWarmer warmer = mock(RuntimeWarmer.class);
HarnessCoordinator coordinator = new HarnessCoordinator(store, harness,
new HarnessEventProjector(), warmer, directExecutor(),
Clock.systemUTC(), new ManagedAgentProperties());
try {
coordinator.cancel("tenant", "session", "turn");
verify(store).requireSession("tenant", "session");
verify(harness).isWorkspaceFilesAvailable();
verifyNoMoreInteractions(harness);
verifyNoInteractions(warmer);
} finally {
coordinator.close();
}
}
@Test
void cancelsABoundTurnThroughTheHarnessWithTheWorkspaceOptIn() {
AgentStateStore store = boundCancellingStore();
HarnessConnector harness = mock(HarnessConnector.class);
when(harness.isWorkspaceFilesAvailable()).thenReturn(true);
when(store.bindHarness(eq("tenant"), eq("session"), eq("turn"),
anyString(), eq("boot"))).thenReturn(true);
HarnessCoordinator coordinator = coordinator(store, harness);
try {
coordinator.cancel("tenant", "session", "turn");
// 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(),
anyBoolean(), anyBoolean());
verify(harness).cancel("tenant", "session");
} finally {
coordinator.close();
}
}
@ParameterizedTest
@ValueSource(strings = {"accepted", "retry", "lease-lost", "completed"})
void runningOwnerObservesCancellationAfterStreamingStarts(String mode)
throws Exception {
AgentStateStore store = boundCancellingStore();
TurnRecord running = turn("tenant", "session", "turn", "prompt",
"epoch", 1);
AtomicReference<TurnRecord> 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);
when(harness.recoverManagedRuntime("tenant", "session", false))
.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.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();
}
}
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(
turn("tenant", "session", "turn", "prompt", "epoch", 1,
"CANCELLING")));
@@ -222,16 +357,7 @@ class HarnessCoordinatorTest {
null, "ACTIVE", "boot", "epoch", 1, 1, 0, 1, 1, null, 1,
new ContextBinding("tenant", "ws-a", 1,
"storage-a", ".", "config-a", 1)));
HarnessCoordinator coordinator = new HarnessCoordinator(store, harness,
new HarnessEventProjector(), warmer, directExecutor(),
Clock.systemUTC(), new ManagedAgentProperties());
try {
coordinator.cancel("tenant", "session", "turn");
verify(store).requireSession("tenant", "session");
verifyNoInteractions(harness, warmer);
} finally {
coordinator.close();
}
return store;
}
@Test
@@ -390,7 +390,8 @@ public final class RuntimeBindingRecord {
&& stopEvidence != null) {
throw new IllegalArgumentException("Recovery evidence cannot be overwritten");
}
if ((state == State.LOST && replacement.state != State.LOST)
if ((state == State.LOST && replacement.state != State.LOST
&& !(replacement.state == State.RELEASED && replacement.drainReceipt != null))
|| state == State.OPERATOR_RECOVERY
&& replacement.state != State.OPERATOR_RECOVERY
&& replacement.state != State.LOST) {
@@ -194,7 +194,6 @@ public final class RuntimeBrokerService implements AutoCloseable {
return CompletableFuture.completedFuture(null);
}
if (!saved.getRequest().isManagedContext() || !provisioner.supportsDrainedStop()
|| saved.getState() == RuntimeBindingRecord.State.LOST
|| saved.getState() == RuntimeBindingRecord.State.OPERATOR_RECOVERY
|| saved.getState() == RuntimeBindingRecord.State.FAILED) {
return failed(conflict("workspace_close_identity_unverified", "Original worker needs recovery"));
@@ -214,8 +213,14 @@ public final class RuntimeBrokerService implements AutoCloseable {
if (claimed == null) {
return failed(unavailable("runtime_close_claim_pending", "Original binding is still claimed"));
}
boolean lost = claimed.getState() == RuntimeBindingRecord.State.LOST;
if (lost && (sessionRepository.countActiveByBinding(claimed.getBindingId(), claimed.getGeneration()) != 0
|| executionRepository.hasActiveByBinding(claimed.getBindingId(), claimed.getGeneration()))) {
releaseOperationQuietly(claimed.getBindingId(), claimed.getOperationGeneration());
return failed(conflict("workspace_close_execution_unsettled", "Lost Runtime resources require recovery"));
}
var draining = bindingRepository.compareAndSet(claimed, claimed.withDrainRequested(true, clock.instant())
.withState(RuntimeBindingRecord.State.DRAINING,
.withState(lost ? RuntimeBindingRecord.State.LOST : RuntimeBindingRecord.State.DRAINING,
claimed.getLease(), clock.instant()));
if (draining == null) {
releaseOperationQuietly(claimed.getBindingId(), claimed.getOperationGeneration());
@@ -183,12 +183,12 @@ class RuntimeHarnessDrainTest {
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void drainingBlocksRivalStoragePlacementUntilStopIsProven(boolean jdbc) throws Exception {
@CsvSource({"false,RECOVERY_BLOCKED", "true,RECOVERY_BLOCKED", "false,LOST", "true,LOST"})
void drainingBlocksRivalStoragePlacementUntilStopIsProven(boolean jdbc, String state) throws Exception {
RuntimeBindingRepository registry = jdbc ? bindings : new InMemoryRuntimeBindingRepository();
var ready = ready(registry);
var claimed = registry.claimOperation(ready.getBindingId(), "block", Duration.ofSeconds(10));
var blocked = registry.compareAndSet(claimed, claimed.withState(RuntimeBindingRecord.State.RECOVERY_BLOCKED,
var blocked = registry.compareAndSet(claimed, claimed.withState(RuntimeBindingRecord.State.valueOf(state),
claimed.getLease(), Instant.now()));
assertNotNull(blocked);
var binding = registry.releaseOperation(blocked.getBindingId(), "block", blocked.getOperationGeneration());
@@ -203,7 +203,8 @@ class RuntimeHarnessDrainTest {
provisioner, transport, registry, sessions, executions, "drainer", Duration.ofSeconds(2), Duration.ofSeconds(2))) {
service.requestHarnessDrain("tenant", "harness");
var close = service.drainHarnessSession("tenant", "harness").toCompletableFuture();
assertEquals(RuntimeBindingRecord.State.DRAINING, registry.findById(binding.getBindingId()).getState());
assertEquals("LOST".equals(state) ? RuntimeBindingRecord.State.LOST : RuntimeBindingRecord.State.DRAINING,
registry.findById(binding.getBindingId()).getState());
assertEquals("runtime_placement_recovery_required",
assertThrows(RuntimeBrokerException.class, () -> registry.findOrCreate(rival)).getCode());
registry.findOrCreate(new RuntimeProvisionRequest(rivalScope, "unrelated", "local-process", "other-storage"));
@@ -266,6 +267,28 @@ class RuntimeHarnessDrainTest {
}
}
@Test
void lostBindingWithAnUnreleasedSessionStillRequiresRecovery() throws Exception {
var binding = ready();
var session = bindings.admitSession(sessions, candidate(binding, "one"));
var claimed = bindings.claimOperation(binding.getBindingId(), "setup", Duration.ofSeconds(10));
var lost = bindings.compareAndSet(claimed, claimed.withState(RuntimeBindingRecord.State.LOST,
claimed.getLease(), Instant.now()));
bindings.releaseOperation(lost.getBindingId(), "setup", lost.getOperationGeneration());
try (var service = service()) {
service.requestHarnessDrain("tenant", "harness");
var failure = assertThrows(ExecutionException.class,
() -> service.drainHarnessSession("tenant", "harness").toCompletableFuture().get());
assertEquals("workspace_close_execution_unsettled",
assertInstanceOf(RuntimeBrokerException.class, failure.getCause()).getCode());
assertTrue(sessions.findById(scope, session.getRuntimeSessionId()).isActive());
assertEquals(RuntimeBindingRecord.State.LOST, bindings.findById(binding.getBindingId()).getState());
assertNull(bindings.findById(binding.getBindingId()).getDrainReceipt());
assertEquals(0, transport.releases);
assertEquals(0, provisioner.stops);
}
}
@Test
void unknownExecutionBlocksReleaseAndStop() throws Exception {
var binding = ready();
@@ -823,11 +823,45 @@ describe('ManagedSessionsPage', () => {
expect(
container.querySelector('[data-managed-workspace-binding]')?.textContent,
).toContain('services/api');
expect(
container.querySelector('[data-managed-workspace-binding]')?.textContent,
).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');
});
it('lets the creator send a later Turn to a bound Session', async () => {
mocks.client.getSession.mockResolvedValue(
summary('bound', {
activeTurnId: undefined,
workspace: { workspaceId: 'ws-a', cwdRelative: 'services/api' },
capabilities: { canSend: true, canCancel: false, workspaceTurns: true },
}),
);
mocks.client.submitPrompt.mockResolvedValue({
sessionId: 'bound',
turnId: 'p2',
});
await render('bound');
expect(
container.querySelector('[data-managed-workspace-binding]')?.textContent,
).toContain('ws-a');
expect(
container.querySelector('[data-managed-workspace-binding]')?.textContent,
).not.toContain('You cannot send messages in this Session');
expect(container.querySelector('textarea')).not.toBeNull();
await input('Run it again');
await click('Send');
expect(mocks.client.submitPrompt).toHaveBeenCalledWith(
'bound',
{ text: 'Run it again' },
expect.objectContaining({ idempotencyKey: expect.any(String) }),
);
});
async function click(label: string) {
const button = [...container.querySelectorAll('button')].find(
(item) => item.textContent === label,
@@ -485,9 +485,11 @@ function ManagedSessionsContent({
{t('managed.workspaceDirectory')}:{' '}
{summary.workspace.cwdRelative}
</p>
<p className="text-muted-foreground">
{t('managed.workspaceExecutionUnavailable')}
</p>
{!summary.capabilities.workspaceTurns && (
<p className="text-muted-foreground">
{t('managed.workspaceExecutionUnavailable')}
</p>
)}
</div>
)}
{summary?.failure && (
@@ -615,7 +617,7 @@ function ManagedSessionsContent({
}
loading={detail.loading}
/>
{!summary?.workspace &&
{(!summary?.workspace || summary.capabilities.workspaceTurns) &&
(!provider.workspaceBinding || (sessionId && summary)) ? (
<form
className="flex shrink-0 flex-col gap-2"
@@ -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, while the creator currently holds Workspace read and create grants on a registry row whose state is ACTIVE and the Session is an active, undeleted qwen-code Session on the frozen execution profile; Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. 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;
@@ -496,6 +496,11 @@ export interface components {
WebShellSessionCapabilities: {
/** @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 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;
tasks: boolean;
artifacts: boolean;
/**
@@ -647,7 +652,7 @@ export interface components {
* @default false
*/
workspaceContext: boolean;
/** @description Supports authorized Workspace discovery, Session creation, and saved binding read-back. This capability does not advertise execution readiness. Deployments may separately opt in to an initial Workspace Read/Write/Edit Turn at creation; later submit, cancel and lifecycle operations remain gated. */
/** @description Supports authorized Workspace discovery, Session creation, and saved binding read-back. This capability does not advertise execution readiness. Deployments may separately opt in to an initial Workspace Read/Write/Edit Turn at creation; the Session creator may submit and cancel later Turns under the same opt-in, while close follows its separate capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close. */
workspaceBinding: boolean;
};
/** @description Same authorized explicit default as default_workspace, including when outside this page; null if absent or not creatable. A non-null default is active and has canCreateSession=true. */
@@ -608,6 +608,67 @@ describe('createJavaManagedAgentProvider', () => {
).toEqual({ canSend: true, canCancel: false });
});
it('lets a bound Session send only when the service allows its caller', async () => {
const bound = {
sessionId: 'bound-1',
status: 'ACTIVE',
createdAt: 1,
updatedAt: 1,
lastSequence: 3,
workspace: { workspaceId: 'ws-a', cwdRelative: '.' },
};
const provider = createJavaManagedAgentProvider({
baseUrl: 'https://product.example',
fetch: vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
jsonResponse({
...bound,
capabilities: { tasks: true, workspaceTurns: true },
}),
)
.mockResolvedValueOnce(
jsonResponse({
...bound,
capabilities: { tasks: true, workspaceTurns: false },
}),
),
});
expect(
(await provider.getSession('bound-1', { clientId: 'c' })).capabilities,
).toEqual({ canSend: true, canCancel: false, workspaceTurns: true });
expect(
(await provider.getSession('bound-1', { clientId: 'c' })).capabilities,
).toEqual({ canSend: false, canCancel: false });
});
it('lets the allowed caller cancel a running Turn of a bound Session', async () => {
const provider = createJavaManagedAgentProvider({
baseUrl: 'https://product.example',
fetch: vi.fn<typeof fetch>().mockResolvedValue(
jsonResponse({
sessionId: 'bound-1',
status: 'ACTIVE',
createdAt: 1,
updatedAt: 2,
lastSequence: 5,
workspace: { workspaceId: 'ws-a', cwdRelative: '.' },
activeTurn: {
turnId: 'turn-2',
sessionId: 'bound-1',
status: 'RUNNING',
submittedAt: 2,
},
capabilities: { tasks: true, workspaceTurns: true },
}),
),
});
expect(
(await provider.getSession('bound-1', { clientId: 'c' })).capabilities,
).toEqual({ canSend: false, canCancel: true, workspaceTurns: true });
});
it('passes download cancellation through the host sink to the content fetch', async () => {
const abort = new AbortController();
const fetchImpl = vi
@@ -270,6 +270,10 @@ function toSessionSummary(
turnStatus,
);
const sessionActive = session.status.toLowerCase() === 'active';
// A bound Session takes later Turns only from the caller the service
// allows; everything else about a bound Session stays read-only.
const workspaceTurns =
Boolean(session.workspace) && session.capabilities?.workspaceTurns === true;
const errorCode =
session.activeTurn?.errorCode ?? session.environment?.errorCode;
return {
@@ -287,12 +291,14 @@ function toSessionSummary(
runtimeState,
capabilities: {
...(session.capabilities?.artifacts === true ? { artifacts: true } : {}),
canSend: sessionActive && !active && !session.workspace,
canSend:
sessionActive && !active && (!session.workspace || workspaceTurns),
canCancel:
sessionActive &&
active &&
turnStatus !== 'cancelling' &&
!session.workspace,
(!session.workspace || workspaceTurns),
...(workspaceTurns ? { workspaceTurns: true } : {}),
...(session.capabilities?.actions === true ? { actions: true } : {}),
},
...(errorCode ? { failure: { code: errorCode, message: errorCode } } : {}),
@@ -35,6 +35,8 @@ export interface ManagedAgentSessionSummary {
canCancel: boolean;
actions?: boolean;
artifacts?: boolean;
/** The caller may submit later Turns to this Workspace-bound Session. */
workspaceTurns?: boolean;
};
failure?: { code: string; message: string };
}
+2 -2
View File
@@ -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': '没有可读取的工作区。',