From 5b31445bb1cd2c464b021f6e78943b0a1ddf3ea5 Mon Sep 17 00:00:00 2001 From: jinye Date: Sun, 4 Oct 2026 08:33:34 +0800 Subject: [PATCH 01/17] feat(managed-agent): add reliable ACTIVE Workspace deletion --- .../workspace-session-active-delete-l3.md | 131 +++++++ ...orkspace-session-active-delete-l3.zh-CN.md | 105 +++++ .../workspace-session-archive-delete-l1-l2.md | 2 + ...pace-session-archive-delete-l1-l2.zh-CN.md | 2 + .../workspace-session-reliable-close.md | 2 + .../workspace-session-reliable-close.zh-CN.md | 2 + .../src/serve/hosted-harness-contract.test.ts | 1 + .../cli/src/serve/hosted-harness-contract.ts | 1 + .../src/serve/hosted-harness-session.test.ts | 289 +++++++++++++- .../cli/src/serve/hosted-harness-session.ts | 182 ++++++++- packages/cli/src/serve/hosted-hook-session.ts | 34 +- .../cli/src/serve/hosted-workspace-broker.ts | 27 +- packages/cli/src/serve/types.ts | 1 + .../http-managed-session-store.test.ts | 122 +++++- .../http-managed-session-store.ts | 57 +++ .../managed-session-authority.ts | 2 + .../managed-session-storage.ts | 9 + .../api/ManagedSessionStoreController.java | 50 ++- .../harness/HarnessConnector.java | 10 + .../harness/QwenHostedHarnessConnector.java | 48 +++ .../service/EmbeddedRuntimeBroker.java | 55 +-- .../service/ManagedAgentService.java | 12 +- .../service/SessionLifecycleCoordinator.java | 41 ++ .../service/SessionLifecycleService.java | 5 +- .../service/WorkspaceRuntimeProvisioner.java | 4 +- .../service/WorkspaceRuntimeResolver.java | 22 +- .../service/WorkspaceRuntimeTransport.java | 9 +- .../managedagent/store/AgentStateStore.java | 8 + .../managedagent/store/ManagedAgentStore.java | 96 ++++- .../store/ManagedExtensionRecordStore.java | 66 ++++ .../store/ManagedSessionStore.java | 123 +++++- .../store/ManagedSessionStoreModels.java | 10 + .../code/managedagent/store/StoreModels.java | 8 +- .../store/WorkspaceExecutionStore.java | 33 +- .../store/WorkspaceLifecycleStore.java | 360 ++++++++++++++++++ .../V35__workspace_session_lifecycle.sql | 6 + .../managed-agent-public-api.openapi.json | 18 +- .../ManagedSessionLifecycleTest.java | 17 +- .../WorkspaceSessionRetentionMySqlIT.java | 1 + .../service/WorkspaceRuntimeTest.java | 28 ++ .../store/WorkspaceLifecycleMySqlIT.java | 104 +++++ .../store/WorkspaceLifecycleStoreTest.java | 221 +++++++++++ .../daemon/HostedHarnessCapabilities.java | 9 + .../qwen/code/daemon/HostedHarnessClient.java | 28 +- .../qwen/code/daemon/LoadHarnessSession.java | 14 + .../code/daemon/HostedHarnessClientTest.java | 49 +++ .../runtimebroker/HarnessSessionResolver.java | 8 + .../InMemoryRuntimeBindingRepository.java | 7 + .../JdbcRuntimeBindingRepository.java | 144 ++++++- .../JdbcRuntimeBrokerSchema.java | 4 + .../runtimebroker/RuntimeBindingRecord.java | 9 + .../RuntimeBindingRepository.java | 21 + .../RuntimeBrokerHttpServer.java | 37 +- .../runtimebroker/RuntimeBrokerService.java | 128 ++++++- .../RuntimeLifecycleAuthority.java | 14 + .../qwen/code/runtimebroker/RuntimeScope.java | 16 + .../qwen/code/runtimebroker/schema.sql | 4 + .../RuntimeBrokerServiceTest.java | 40 ++ 58 files changed, 2743 insertions(+), 113 deletions(-) create mode 100644 docs/design/workspace-session-active-delete-l3.md create mode 100644 docs/design/workspace-session-active-delete-l3.zh-CN.md create mode 100644 packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStore.java create mode 100644 packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V35__workspace_session_lifecycle.sql create mode 100644 packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleMySqlIT.java create mode 100644 packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java create mode 100644 packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeLifecycleAuthority.java diff --git a/docs/design/workspace-session-active-delete-l3.md b/docs/design/workspace-session-active-delete-l3.md new file mode 100644 index 00000000000..0bcd647a0b6 --- /dev/null +++ b/docs/design/workspace-session-active-delete-l3.md @@ -0,0 +1,131 @@ +# Reliable deletion of ACTIVE Workspace Sessions (L3) + +[English](workspace-session-active-delete-l3.md) | [简体中文](workspace-session-active-delete-l3.zh-CN.md) + +## 1. Status and scope + +Implemented locally on `codex/workspace-session-l3`, 2026-10-03. Linux physical +acceptance remains pending. This implements L3 of +[#13164](https://github.com/QwenLM/qwen-code/issues/13164), on main after +reliable close #13135, L1/L2 #13194, O4 retirement #13084 and H2 Hooks #13129. +It admits deletion of idle `hosted-workspace-files/1` Sessions through the +existing public and WebShell routes. Shell/MCP profiles, buttons, physical +erasure, new roles and abandoning unknown effects remain out of scope. + +| Operation | Lifecycle Hooks | Result | +| ---------------------- | ------------------------------ | -------------------------------------------- | +| ACTIVE close | SessionEnd | CLOSED, retained data | +| ACTIVE delete | SessionEnd, then SessionDelete | Reliable stop, atomic retirement and DELETED | +| CLOSED/ARCHIVED delete | None, including deferred Hooks | Existing L2 metadata deletion | +| Detach | None | Attachment cleanup | + +The current-readable creator remains the mutation authority. An accepted, +running, cancelling or approval-waiting Turn rejects admission with +`409 turn_active`. Idempotency, actor isolation and tombstone visibility retain +their L2 semantics. Once means reusing committed outcomes and never redispatching +an attempt that might have begun, not guaranteed eventual completion. + +## 2. Protocol and evidence + +Add private `POST /session/:id/lifecycle`, carrying Session scope, operationId, +kind (`close` or `delete`) and claimGeneration. It settles only the required +Hooks and returns references to committed H2 records, retaining the attachment, +writer and owner until Java accepts those effects. Java validates authoritative +Session-store records; HTTP success alone is not proof. + +Operations save a lifecycle protocol version and an intermediate effects receipt. +The receipt binds Session and operation identity to required event occurrences, +plans and committed result references, or verified no-Hook evidence. The public +receipt_id continues to be issued only by final completion. + +The receipt is not a substitute for worker-stop evidence. Final completion checks +the permanent fence, writer exclusion, all original Runtime resources and +matching binding/generation/handle stop proofs. RELEASED alone is insufficient; +historical bindings without verifiable stop or never-started evidence block. + +The public routes and 202 operation responses remain unchanged. Advertise +session_delete/sessionDelete for supported ACTIVE files Sessions independently +of archive/unarchive. Capability denotes support, not authorization or idle +state. Keep the aggregate session_lifecycle unchanged. + +## 3. Execution and recovery + +Admission saves CLOSING/DELETING and a durable LIFECYCLE_ONLY fence together. +Ordinary warm, acquire, tool execution, input and other control mutations are +excluded. Only the current operation and live claim may obtain lifecycle writer +and execution authority in the original Workspace scope. This authority still +requires private authentication and current execution authorization. Use the +existing lock hierarchy to prevent authorization/admission races. + +Settle earlier operations, excluding this operation's lifecycle occurrences from +generic cancellation. Stable occurrence IDs derive from Session, operation and +event. Reuse H2 catalog, plan, dispatch intent and results. SessionDelete starts +only after SessionEnd's children have committed their outcomes, including async +Hooks; a settled plan marker alone is insufficient. Cold load restores saved state without a user +Turn or startup Hook. Reuse a verifiable original Runtime; a replacement +generation never replays effects. First initialization is permitted only when +no Runtime has ever been created and authorization is valid. + +Before each new side-effect dispatch, check current ACL, mount and identity. +After revocation, lookup and settlement of already dispatched work continue; +undispatched Hooks stay recovery_blocked until authority returns. Unknown effects +retain ownership and do not become cancellation or completion proofs. This +inherits H2's potentially indefinite blocking, tracked separately in #13133. + +Precheck authority before both plan and child dispatch, and recheck inside the +journal commit transaction. A definite transactional authorization refusal leaves +the writer authority retryable without consuming journal sequences. A missing +response, or a refusal after an uncertain request, retains the write failure fence. + +After validating and saving the effects receipt, upgrade the fence monotonically +to DRAINING. Detach without Hooks, release owners, seal the writer, then use the +reliable-close drain/stop protocol. A successor skips Hooks when the effects +receipt is saved, otherwise reconstructs progress from the same H2 occurrences. +Harness 404, lease expiry and worker disappearance cannot establish completion. + +Final completion requires the live delivery claim, effects receipt, permanent +fence, no live writer or unsettled execution, and verifiable original stop +proofs. CLOSE commits CLOSED. DELETE commits O4 retirement, DELETED, operation +completion and the terminal event atomically. Shared files and other Sessions' +holders remain intact. + +For a never-initialized Session, acquire the tenant-retention, public Session and +journal-head exclusion in writer order, and prove no completed bootstrap, live +writer, journal or Hook dispatch records. Save never-initialized no-Hook evidence. +With a header, inspect the original definition/catalog instead. Either case +still checks the complete Runtime binding set after the permanent fence. + +## 4. Compatibility and rollout + +Add a new migration; preserve V32. Historical admitted operations retain their +original protocol and evidence, without new Hook identities. +Live protocol-zero close attachments retain their legacy DELETE and original +Hook control path only while their persisted close claim is valid. Ordinary +execution remains fenced; the exception cannot authorize L3 or MCP execution. +Upgrade all +coordinators and Harnesses before enabling new L3 admission. A missing protocol +capability rejects admission rather than falling back to legacy DELETE. Do not +roll back to old coordinators while L3 operations remain unfinished. L2 +CLOSED/ARCHIVED deletion remains independent of Harness availability. + +Implementation order: protocol and fences; scoped authority and receipts; +close semantics; ACTIVE delete admission and capability; recovery validation. +Synchronize canonical OpenAPI and related bilingual designs. + +## 5. Validation and acceptance + +Cover both HTTP surfaces, replay/actor isolation, every active Turn state, empty +Sessions, absent catalogs and empty Hook plans. Assert close End=1/Delete=0, +ACTIVE delete End=1/Delete=1, and detach/L2 Hook=0. Inject lost replies and crashes +around dispatch, result commit, receipt, detach, stop and tombstone. A second +server must resume without duplicate effects; stale claims cannot advance. + +Exercise ACL/mount revocation between Hooks, restoration, unknown effects, +unverifiable identity, historical missing stop proof, and ordinary admission +races. Real MySQL checks transaction rollback and concurrency. Real Linux checks +Harness/workers, host/boot/PID identity, retained shared files and neighbor +holders. Simulation is reported separately from physical validation. + +Run build, typecheck, bundle, focused TS/Java tests and the E2E plan, then two +clean self-audit passes and independent review. Results and environment limits +are recorded in `.qwen/e2e-tests/workspace-session-active-delete-l3.md`. diff --git a/docs/design/workspace-session-active-delete-l3.zh-CN.md b/docs/design/workspace-session-active-delete-l3.zh-CN.md new file mode 100644 index 00000000000..d1f1da3f123 --- /dev/null +++ b/docs/design/workspace-session-active-delete-l3.zh-CN.md @@ -0,0 +1,105 @@ +# ACTIVE Workspace 会话的可靠删除(L3) + +[English](workspace-session-active-delete-l3.md) | [简体中文](workspace-session-active-delete-l3.zh-CN.md) + +## 1. 状态与范围 + +已在 `codex/workspace-session-l3` 本地实现,2026-10-03,Linux 实体验收仍待完成。本文实现 +[#13164](https://github.com/QwenLM/qwen-code/issues/13164) 的 L3,基于 main 已合入的 +可靠 close #13135、L1/L2 #13194、O4 退役 #13084 和 H2 Hooks #13129。 +通过既有公开和 WebShell 路由接纳空闲的 `hosted-workspace-files/1` 会话删除。 +Shell/MCP profile、按钮、物理擦除、新角色和放弃 unknown 副作用不在范围内。 + +| 操作 | 生命周期 Hook | 结果 | +| ---------------------- | ---------------------------- | ---------------------------- | +| ACTIVE close | SessionEnd | CLOSED,保留数据 | +| ACTIVE delete | SessionEnd,再 SessionDelete | 可靠停机,原子退役与 DELETED | +| CLOSED/ARCHIVED delete | 不运行,也不补跑 Hook | 既有 L2 元数据删除 | +| Detach | 不运行 | 清理 attachment | + +mutation 仍要求当前可读的创建者。已接纳、运行中、取消中或等待审批的 Turn +返回 `409 turn_active`。幂等、actor 隔离和墓碑可见性沿用 L2。 +运行一次指复用已提交结果,不重新派发可能已开始的尝试,不承诺最终一定完成。 + +## 2. 协议与证据 + +新增私有 `POST /session/:id/lifecycle`,携带 Session scope、operationId、 +kind(`close` 或 `delete`)和 claimGeneration。它只结算所需 Hook,返回已提交 +H2 记录的引用;Java 接受这些副作用前保留 attachment、writer 和 owner。 +Java 核对权威 Session Store 记录,HTTP 成功本身不是证明。 + +operation 保存生命周期协议版本和中间 effects receipt。回执将 Session 和操作 +身份绑定到所需事件的 occurrence、plan、已提交结果引用,或经过核验的无 Hook +证据。公开 receipt_id 仍仅在最终完成时生成。 + +该回执不能替代 worker 停机证明。最终完成检查永久围栏、writer 排他、全部原 +Runtime 资源及匹配 binding/generation/handle 的停机证明。RELEASED 本身不足; +历史 binding 缺少可核验的 stop 或 never-started 证据时阻塞。 + +公开路由和 202 operation 响应不变。对支持的 ACTIVE files 会话独立宣告 +session_delete/sessionDelete,不联动 archive/unarchive。capability 表示支持, +不表示授权或空闲状态。聚合 session_lifecycle 保持原值。 + +## 3. 执行与恢复 + +准入一起保存 CLOSING/DELETING 和持久 LIFECYCLE_ONLY 围栏,禁止普通 warm、 +acquire、工具执行、输入和其他控制 mutation。仅当前 operation 与有效 claim +可以在原 Workspace scope 获取生命周期 writer 和执行权限。该权限仍要求 +私有认证和当前执行授权。使用既有锁层级消除授权与执行准入之间的竞态。 + +先结算此前操作,通用取消不得包含本 operation 的生命周期 occurrence。 +稳定 occurrence ID 由 Session、operation、事件派生,复用 H2 的 catalog、plan、 +dispatch intent 和结果。SessionEnd 的全部子执行提交结果后才开始 SessionDelete, +包括异步 Hook;只有 plan marker 结算不足。冷加载只恢复 +保存状态,不创建用户 Turn 或 startup Hook。复用可核验的原 Runtime,不用替代 +代际重放副作用。仅从未创建 Runtime 且授权有效时允许首次初始化。 + +每次新副作用派发前核对当前 ACL、挂载和身份。撤销后仍可查询、结算已派发工作; +未派发 Hook 保持 recovery_blocked,直至恢复权限。unknown 保留所有权,不能 +变成取消或完成证明。沿用 H2 可能无限期阻塞的限制,独立由 #13133 跟踪。 + +plan 和子执行派发前均预检权限,并在 journal 提交事务中重新检查。明确的事务 +授权拒绝不消耗 journal sequence,writer authority 可继续重试。应答丢失,或 +不确定请求后再收到拒绝,仍保留写入失败围栏。 + +核对并保存 effects receipt 后,围栏单向升级到 DRAINING。不运行 Hook 地 detach, +释放 owner、seal writer,再使用可靠 close 的 drain/stop 协议。接管者在 effects +receipt 已保存时跳过 Hook,否则从相同 H2 occurrence 恢复进度。Harness 404、 +租约过期或 worker 消失均不能证明完成。 + +最终完成要求有效 delivery claim、effects receipt、永久围栏、无有效 writer 或 +未结算执行,以及可核验的原停机证明。CLOSE 提交 CLOSED;DELETE 原子提交 O4 +退役、DELETED、operation 完成与终止事件。共享文件及其他会话的 holder 保留。 + +从未初始化的会话按 writer 顺序取得 tenant-retention、公开 Session 和 journal +head 排他,证明无已完成 bootstrap、有效 writer、journal 或 Hook 派发记录, +再保存 never-initialized 无 Hook 证据。有 header 时核对原 definition/catalog。 +两种情况都必须在永久围栏之后检查完整 Runtime binding 集合。 + +## 4. 兼容与启用 + +新增迁移,保留 V32。升级前已接纳的操作沿用原协议与证据,不产生新 Hook 身份。 +仍存活的 protocol-zero close attachment 仅在持久 close claim 有效时保留原 DELETE +和原 Hook control 路径。普通执行继续被围栏阻止;此例外不能授权 L3 或 MCP 执行。 +启用 L3 准入前升级全部 coordinator 和 Harness。缺少新协议能力时拒绝准入, +不回退到旧 DELETE。存在未完成 L3 操作时不回滚到旧 coordinator。 +L2 CLOSED/ARCHIVED 删除继续独立于 Harness 可用性。 + +实施顺序:协议与围栏;限定权限和回执;close 语义;ACTIVE delete 准入与 +capability;恢复验证。同步 canonical OpenAPI 和相关中英文设计。 + +## 5. 验证与验收 + +覆盖两个 HTTP 入口、重放与 actor 隔离、全部活跃 Turn 状态、空会话、无 catalog +与空 Hook plan。断言 close End=1/Delete=0、ACTIVE delete End=1/Delete=1、 +detach/L2 Hook=0。在 dispatch、结果提交、receipt、detach、stop 和墓碑边界 +注入应答丢失与崩溃。第二服务接管不能重复副作用,旧 claim 不能推进。 + +覆盖两个 Hook 之间撤销 ACL/挂载及恢复、unknown、身份无法核验、历史缺失停机 +证明和普通执行准入竞态。真实 MySQL 验证事务回滚与并发。真实 Linux 验证 +Harness/worker、host/boot/PID 身份、共享文件和邻居 holder 保留。 +模拟与物理验证分别报告。 + +执行 build、typecheck、bundle、定向 TS/Java 测试及 E2E 计划,再完成两轮干净 +自审和独立评审。结果和环境限制记录在 +`.qwen/e2e-tests/workspace-session-active-delete-l3.md`。 diff --git a/docs/design/workspace-session-archive-delete-l1-l2.md b/docs/design/workspace-session-archive-delete-l1-l2.md index fddd43c4464..fed49ffce5f 100644 --- a/docs/design/workspace-session-archive-delete-l1-l2.md +++ b/docs/design/workspace-session-archive-delete-l1-l2.md @@ -2,6 +2,8 @@ [English](workspace-session-archive-delete-l1-l2.md) | [简体中文](workspace-session-archive-delete-l1-l2.zh-CN.md) +This document preserves the original L1/L2 and reliable-close protocol. Newly admitted ACTIVE files close/delete use [L3](workspace-session-active-delete-l3.md): close runs SessionEnd only, delete runs SessionEnd followed by SessionDelete, and durable Hook evidence precedes permanent draining. L2 CLOSED/ARCHIVED deletion remains independent of Harness; operations admitted before upgrade resume under their saved protocol. + ## 1. Status and baseline Implemented and synchronized with main, 2026-10-02, on `codex/workspace-session-l1-l2`. This document diff --git a/docs/design/workspace-session-archive-delete-l1-l2.zh-CN.md b/docs/design/workspace-session-archive-delete-l1-l2.zh-CN.md index c67dc34ca44..17cd976a740 100644 --- a/docs/design/workspace-session-archive-delete-l1-l2.zh-CN.md +++ b/docs/design/workspace-session-archive-delete-l1-l2.zh-CN.md @@ -2,6 +2,8 @@ [English](workspace-session-archive-delete-l1-l2.md) | [简体中文](workspace-session-archive-delete-l1-l2.zh-CN.md) +本设计保留 L1/L2 与可靠 close 的原有协议。新接纳的 ACTIVE files 会话 close/delete 采用 [L3 设计](workspace-session-active-delete-l3.zh-CN.md):close 仅运行 SessionEnd,delete 顺序运行 SessionEnd 与 SessionDelete,先提交 Hook 证据再永久停机。CLOSED/ARCHIVED 的 L2 删除仍不依赖 Harness;升级前接纳的 operation 按原版本恢复。 + ## 1. 状态与基线 实现完成并已同步 main,2026-10-02,分支 `codex/workspace-session-l1-l2`。本文覆盖 diff --git a/docs/design/workspace-session-reliable-close.md b/docs/design/workspace-session-reliable-close.md index 458f23ec1d1..da04f396c87 100644 --- a/docs/design/workspace-session-reliable-close.md +++ b/docs/design/workspace-session-reliable-close.md @@ -2,6 +2,8 @@ [English](workspace-session-reliable-close.md) | [简体中文](workspace-session-reliable-close.zh-CN.md) +This document preserves the original L1/L2 and reliable-close protocol. Newly admitted ACTIVE files close/delete use [L3](workspace-session-active-delete-l3.md): close runs SessionEnd only, delete runs SessionEnd followed by SessionDelete, and durable Hook evidence precedes permanent draining. L2 CLOSED/ARCHIVED deletion remains independent of Harness; operations admitted before upgrade resume under their saved protocol. + ## Status and scope Implemented and locally verified. This slice enables idle public `hosted-workspace-files/1` Sessions to close through existing public and WebShell lifecycle operations. Running, cancelling, accepted, or approval-waiting Turns remain a `409 turn_active`. Shell, MCP, archive/delete, and a new UI button are excluded. Close preserves transcripts, Artifacts, and shared Workspace files. diff --git a/docs/design/workspace-session-reliable-close.zh-CN.md b/docs/design/workspace-session-reliable-close.zh-CN.md index b5158081ab6..c10f309bb95 100644 --- a/docs/design/workspace-session-reliable-close.zh-CN.md +++ b/docs/design/workspace-session-reliable-close.zh-CN.md @@ -2,6 +2,8 @@ [English](workspace-session-reliable-close.md) | [简体中文](workspace-session-reliable-close.zh-CN.md) +本设计保留 L1/L2 与可靠 close 的原有协议。新接纳的 ACTIVE files 会话 close/delete 采用 [L3 设计](workspace-session-active-delete-l3.zh-CN.md):close 仅运行 SessionEnd,delete 顺序运行 SessionEnd 与 SessionDelete,先提交 Hook 证据再永久停机。CLOSED/ARCHIVED 的 L2 删除仍不依赖 Harness;升级前接纳的 operation 按原版本恢复。 + ## 状态与范围 已实现并完成本地验证。本切片通过现有 public 和 WebShell lifecycle operation,为空闲的公开 `hosted-workspace-files/1` Session 开放 close。运行中、取消中、已接受或等待审批的 Turn 仍返回 `409 turn_active`。Shell、MCP、archive/delete 和新增 UI 按钮不在范围内。close 保留历史、Artifacts 和共享 Workspace 文件。 diff --git a/packages/cli/src/serve/hosted-harness-contract.test.ts b/packages/cli/src/serve/hosted-harness-contract.test.ts index 8c334dc6bb5..9d0787a9463 100644 --- a/packages/cli/src/serve/hosted-harness-contract.test.ts +++ b/packages/cli/src/serve/hosted-harness-contract.test.ts @@ -43,6 +43,7 @@ describe('Hosted Harness private contract', () => { protocolVersions: { current: 1, supported: [1] }, bootId: BOOT_ID, capabilityDigest: DIGEST, + lifecycleProtocolVersion: 1, }); expect(Object.isFrozen(contract)).toBe(true); expect(Object.isFrozen(contract.protocolVersions)).toBe(true); diff --git a/packages/cli/src/serve/hosted-harness-contract.ts b/packages/cli/src/serve/hosted-harness-contract.ts index e7ccbf3c5f2..50c975ddbdd 100644 --- a/packages/cli/src/serve/hosted-harness-contract.ts +++ b/packages/cli/src/serve/hosted-harness-contract.ts @@ -44,6 +44,7 @@ export function createHostedHarnessContract( }), bootId: bootId.toLowerCase(), capabilityDigest, + lifecycleProtocolVersion: 1, }); } diff --git a/packages/cli/src/serve/hosted-harness-session.test.ts b/packages/cli/src/serve/hosted-harness-session.test.ts index 0d2990f91fb..54d2c3fca3b 100644 --- a/packages/cli/src/serve/hosted-harness-session.test.ts +++ b/packages/cli/src/serve/hosted-harness-session.test.ts @@ -11,7 +11,15 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import express from 'express'; import supertest from 'supertest'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type MockInstance, +} from 'vitest'; import * as hostedHistory from './hosted-file-history.js'; import type { HostedFileHistoryState } from './hosted-file-history-protocol.js'; import { @@ -75,6 +83,8 @@ const state = vi.hoisted(() => ({ toolResults: null as DurableToolResultResourceStore | null, publicationRequest: vi.fn(), storeOptions: [] as Array>, + authorizeLifecycle: vi.fn(async () => undefined), + authorizeOrdinary: vi.fn(async (_kind?: 'legacy-close') => undefined), model: vi.fn( async (_input: { signal: AbortSignal; @@ -120,6 +130,9 @@ vi.mock( resourceStore, toolResultResources: state.toolResults ?? resourceStore, assertWritable: state.assertWritable, + setLifecycleAuthority: () => undefined, + authorizeOrdinary: state.authorizeOrdinary, + authorizeLifecycle: state.authorizeLifecycle, publication: { owner: async () => ({ writerId: BOOT_ID, writerGeneration: 1 }), request: (route: string, body: unknown, token?: string) => @@ -283,7 +296,7 @@ const hookPin = { catalogRevision: 1, definitionDigest: 'b'.repeat(64), }; -async function hookApp() { +async function hookApp(asyncEnd = false) { const requests: ManagedHookControl[] = []; const catalog: ManagedHookCatalog = { ...hookPin, @@ -295,7 +308,7 @@ async function hookApp() { hookId: eventName, eventName, sequential: false, - async: false, + async: asyncEnd && eventName === HookEventName.SessionEnd, failClosed: true, onceKey: null, config: { type: 'command' as const }, @@ -311,6 +324,10 @@ async function hookApp() { }; }, ); + vi.spyOn( + HostedWorkspaceBroker.prototype, + 'authorizeLifecycle', + ).mockResolvedValue(); const release = vi .spyOn(HostedWorkspaceBroker.prototype, 'release') .mockResolvedValue(); @@ -365,6 +382,10 @@ describe('Hosted Harness no-tool session', () => { state.toolResults = null; state.assertWritable.mockReset(); state.assertWritable.mockResolvedValue(undefined); + state.authorizeLifecycle.mockReset(); + state.authorizeLifecycle.mockResolvedValue(undefined); + state.authorizeOrdinary.mockReset(); + state.authorizeOrdinary.mockResolvedValue(undefined); state.model.mockReset(); state.publicationRequest.mockReset(); state.model.mockImplementation(async () => ({ @@ -377,6 +398,266 @@ describe('Hosted Harness no-tool session', () => { await rm(state.root, { recursive: true, force: true }); }); + it.each(['close', 'delete'] as const)( + 'settles %s lifecycle Hooks once and retains the attachment until detach', + async (kind) => { + const { server, authorize, requests, release } = await hookApp(); + const operation = { + kind, + sessionKey: { + tenantId: 'tenant', + workspaceId: 'workspace', + sessionId: SESSION_ID, + }, + authority: { operationId: 'op-l3', claimGeneration: 1 }, + }; + const first = await authorize( + supertest(server).post(`/session/${SESSION_ID}/lifecycle`), + ).send(operation); + expect(first.status).toBe(200); + expect( + first.body.effects.map((effect: { event: string }) => effect.event), + ).toEqual( + kind === 'close' ? ['SessionEnd'] : ['SessionEnd', 'SessionDelete'], + ); + expect(release).not.toHaveBeenCalled(); + const retry = await authorize( + supertest(server).post(`/session/${SESSION_ID}/lifecycle`), + ).send({ + ...operation, + authority: { ...operation.authority, claimGeneration: 2 }, + }); + expect(retry.status).toBe(200); + expect(retry.body).toEqual(first.body); + expect( + requests + .filter((request) => request.kind === 'hook-execute') + .map((request) => request.input.hook_event_name), + ).toEqual( + kind === 'close' ? ['SessionEnd'] : ['SessionEnd', 'SessionDelete'], + ); + await authorize(supertest(server).post(`/session/${SESSION_ID}/prompt`)) + .send({}) + .expect(409); + await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .send({ authority: { operationId: 'op-l3', claimGeneration: 2 } }) + .expect(204); + expect(release).toHaveBeenCalled(); + expect(state.model).not.toHaveBeenCalled(); + }, + ); + + it('uses the legacy close admission for an attached protocol zero Session with Hooks', async () => { + const { server, authorize, requests } = await hookApp(); + state.authorizeOrdinary.mockImplementation(async (kind) => { + if (kind !== 'legacy-close') throw new Error('Ordinary admission closed'); + }); + await authorize(supertest(server).post(`/session/${SESSION_ID}/prompt`)) + .send({}) + .expect(409); + await authorize(supertest(server).delete(`/session/${SESSION_ID}`)).expect( + 204, + ); + expect(state.authorizeOrdinary).toHaveBeenLastCalledWith('legacy-close'); + expect( + requests + .filter((request) => request.kind === 'hook-execute') + .map((request) => request.input.hook_event_name), + ).toEqual(['SessionEnd', 'SessionDelete']); + }); + + it('resumes the undispatched Delete Hook after revocation without cancelling or repeating End', async () => { + const { server, authorize, requests } = await hookApp(); + let permitted = true; + const control = vi.mocked(HostedWorkspaceBroker.prototype.hookControl); + const original = control.getMockImplementation()!; + control.mockImplementation(async (request) => { + const result = await original(request); + if (request.kind === 'hook-execute') permitted = false; + return result; + }); + vi.mocked( + HostedWorkspaceBroker.prototype.authorizeLifecycle, + ).mockImplementation(async () => { + if (!permitted) throw new Error('ACL revoked'); + }); + const operation = { + kind: 'delete', + sessionKey: { + tenantId: 'tenant', + workspaceId: 'workspace', + sessionId: SESSION_ID, + }, + authority: { operationId: 'op-revoked', claimGeneration: 1 }, + }; + const first = await authorize( + supertest(server).post(`/session/${SESSION_ID}/lifecycle`), + ).send(operation); + expect(first.status).toBe(503); + expect( + requests + .filter((request) => request.kind === 'hook-execute') + .map((request) => request.input.hook_event_name), + ).toEqual(['SessionEnd']); + vi.mocked( + HostedWorkspaceBroker.prototype.authorizeLifecycle, + ).mockResolvedValue(); + const retry = await authorize( + supertest(server).post(`/session/${SESSION_ID}/lifecycle`), + ).send({ + ...operation, + authority: { ...operation.authority, claimGeneration: 2 }, + }); + expect(retry.status).toBe(200); + expect( + requests + .filter((request) => request.kind === 'hook-execute') + .map((request) => request.input.hook_event_name), + ).toEqual(['SessionEnd', 'SessionDelete']); + expect( + requests.filter((request) => request.kind === 'hook-cancel'), + ).toHaveLength(0); + await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .send({ authority: { operationId: 'op-revoked', claimGeneration: 2 } }) + .expect(204); + }); + + it('waits for an async End child to settle before dispatching Delete', async () => { + const { server, authorize, requests } = await hookApp(true); + const control = vi.mocked(HostedWorkspaceBroker.prototype.hookControl); + const original = control.getMockImplementation()!; + let endSettled = false; + control.mockImplementation(async (request) => { + if ( + !endSettled && + (request.kind === 'hook-status' || + (request.kind === 'hook-execute' && + request.input.hook_event_name === HookEventName.SessionEnd)) + ) { + requests.push(request); + return { operationId: request.operationId, state: 'running' }; + } + return original(request); + }); + const operation = { + kind: 'delete', + sessionKey: { + tenantId: 'tenant', + workspaceId: 'workspace', + sessionId: SESSION_ID, + }, + authority: { operationId: 'async-end', claimGeneration: 1 }, + }; + try { + await authorize( + supertest(server).post(`/session/${SESSION_ID}/lifecycle`), + ) + .send(operation) + .expect(503); + expect( + requests + .filter((request) => request.kind === 'hook-execute') + .map((request) => request.input.hook_event_name), + ).toEqual(['SessionEnd']); + } finally { + endSettled = true; + } + await authorize(supertest(server).post(`/session/${SESSION_ID}/lifecycle`)) + .send({ + ...operation, + authority: { operationId: 'async-end', claimGeneration: 2 }, + }) + .expect(200); + expect( + requests + .filter((request) => request.kind === 'hook-execute') + .map((request) => request.input.hook_event_name), + ).toEqual(['SessionEnd', 'SessionDelete']); + expect( + requests.filter((request) => request.kind === 'hook-cancel'), + ).toEqual([]); + await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .send({ authority: { operationId: 'async-end', claimGeneration: 2 } }) + .expect(204); + }); + + it('allows the valid operation after an invalid lifecycle claim is rejected', async () => { + const { server, authorize, requests } = await hookApp(); + state.authorizeLifecycle.mockRejectedValueOnce(new Error('stale claim')); + const operation = { + kind: 'close', + sessionKey: { + tenantId: 'tenant', + workspaceId: 'workspace', + sessionId: SESSION_ID, + }, + authority: { operationId: 'invalid', claimGeneration: 1 }, + }; + await authorize(supertest(server).post(`/session/${SESSION_ID}/lifecycle`)) + .send(operation) + .expect(503); + expect(requests).toHaveLength(0); + await authorize(supertest(server).post(`/session/${SESSION_ID}/lifecycle`)) + .send({ + ...operation, + kind: 'delete', + authority: { operationId: 'valid', claimGeneration: 2 }, + }) + .expect(200); + expect( + requests + .filter((request) => request.kind === 'hook-execute') + .map((request) => request.input.hook_event_name), + ).toEqual(['SessionEnd', 'SessionDelete']); + await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .send({ authority: { operationId: 'valid', claimGeneration: 2 } }) + .expect(204); + }); + + it('keeps an unknown End blocked without cancelling or redispatching it on takeover', async () => { + const { server, authorize, requests } = await hookApp(); + const control = vi.mocked(HostedWorkspaceBroker.prototype.hookControl); + const original = control.getMockImplementation()!; + control.mockImplementation(async (request) => { + if (request.kind === 'hook-catalog') return original(request); + requests.push(request); + return { operationId: request.operationId, state: 'outcome_unknown' }; + }); + const operation = { + kind: 'delete', + sessionKey: { + tenantId: 'tenant', + workspaceId: 'workspace', + sessionId: SESSION_ID, + }, + authority: { operationId: 'unknown-delete', claimGeneration: 1 }, + }; + await authorize(supertest(server).post(`/session/${SESSION_ID}/lifecycle`)) + .send(operation) + .expect(503); + await authorize(supertest(server).post(`/session/${SESSION_ID}/lifecycle`)) + .send({ + ...operation, + authority: { ...operation.authority, claimGeneration: 2 }, + }) + .expect(503); + expect( + requests + .filter((request) => request.kind === 'hook-execute') + .map((request) => request.input.hook_event_name), + ).toEqual(['SessionEnd']); + expect( + requests.filter((request) => request.kind === 'hook-cancel'), + ).toHaveLength(0); + expect(state.model).not.toHaveBeenCalled(); + control.mockImplementation(original); + await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .send({ + authority: { operationId: 'unknown-delete', claimGeneration: 2 }, + }) + .expect(204); + }); + it('refuses a new prompt while the journal close is still sealing', async () => { const server = await app(); const created = await headers(supertest(server).post('/session')).send({ @@ -7089,7 +7370,7 @@ describe('Hosted Harness Runtime turn takeover', () => { /** Captured per parked turn so tests can assert which loads acquire the * Runtime lease: a cold load never does, a passive takeover adopts it. */ - let acquireSpy: ReturnType; + let acquireSpy: MockInstance; beforeEach(async () => { resetManagedRuntimeDispatchGatesForTest(); diff --git a/packages/cli/src/serve/hosted-harness-session.ts b/packages/cli/src/serve/hosted-harness-session.ts index d1ed2714d85..3aae25bddbd 100644 --- a/packages/cli/src/serve/hosted-harness-session.ts +++ b/packages/cli/src/serve/hosted-harness-session.ts @@ -31,6 +31,8 @@ import { createHttpManagedSessionStores, HTTP_MANAGED_SESSION_STORE_CONTRACT, type HttpToolPublicationOwner, + type HttpManagedSessionStores, + type ManagedSessionLifecycleAuthority, } from '@qwen-code/qwen-code-core/managed-runtime/http-managed-session-store.js'; import { openManagedSession, @@ -173,6 +175,9 @@ interface HostedSession { mcpRecovering?: boolean; approval?: HostedApprovalSettings; waiters: HostedApprovalWaiters; + stores?: HttpManagedSessionStores; + lifecycle?: ManagedSessionLifecycleAuthority; + lifecycleKind?: 'close' | 'delete'; /** A recovery load acquired the Runtime Session for this promptId. On * the cancellation path, only the terminal success route and session * teardown hand it back; retry-inviting refusals deliberately leave it @@ -225,6 +230,26 @@ function object(value: unknown): Record | null { : null; } +function lifecycleAuthority( + value: unknown, +): ManagedSessionLifecycleAuthority | undefined { + if (value === undefined) return undefined; + const fields = object(value); + if ( + !fields || + Object.keys(fields).sort().join(',') !== 'claimGeneration,operationId' || + typeof fields['operationId'] !== 'string' || + !/^[A-Za-z0-9._:-]{1,128}$/u.test(fields['operationId']) || + !Number.isSafeInteger(fields['claimGeneration']) || + (fields['claimGeneration'] as number) < 1 + ) + throw new Error('Invalid lifecycle authority.'); + return { + operationId: fields['operationId'], + claimGeneration: fields['claimGeneration'] as number, + }; +} + function error( res: Response, status: number, @@ -1542,6 +1567,17 @@ export function registerHostedHarnessSessionRoutes( ); return; } + let lifecycle: ManagedSessionLifecycleAuthority | undefined; + try { + lifecycle = lifecycleAuthority(body?.['lifecycleAuthority']); + if (lifecycle) { + if (create) throw new Error('Lifecycle load cannot create a Session.'); + stores.setLifecycleAuthority(lifecycle); + } + } catch { + error(res, 400, 'invalid_hosted_lifecycle_authority'); + return; + } opening.add(sessionId); let managed: ManagedSession | undefined; try { @@ -1655,6 +1691,8 @@ export function registerHostedHarnessSessionRoutes( admissions: new Map(), blocked: false, waiters: new HostedApprovalWaiters(), + stores, + lifecycle, ...(toolProfile ? { toolProfile } : {}), ...(toolProfile === HOSTED_WORKSPACE_SHELL_PROFILE && captureBytes !== undefined @@ -1695,7 +1733,7 @@ export function registerHostedHarnessSessionRoutes( session.mcp = new HostedMcpSession(brokerOptions, managed, mcpServers); if (hookCatalog && brokerOptions) session.hooks = new HostedHookSession( - brokerOptions, + { ...brokerOptions, lifecycleAuthority: () => session.lifecycle }, managed, hookCatalog, session.mcp?.broker, @@ -1708,6 +1746,7 @@ export function registerHostedHarnessSessionRoutes( // the Broker or settle anything. A bare load of a parked Session keeps // refusing with 409 so it never drives a Runtime by accident. const takeover = + !lifecycle && !session.hooks && (body?.['passiveManagedRuntimeRecovery'] === true || body?.['driveRuntimeRecovery'] === true); @@ -1836,6 +1875,7 @@ export function registerHostedHarnessSessionRoutes( return; } } else if ( + !lifecycle && restore.recoveryStatus === 'ok' && (session.publication || session.hooks || fileHistory) && brokerOptions @@ -1972,7 +2012,7 @@ export function registerHostedHarnessSessionRoutes( !recovery ) session.blocked = true; - await settleCancelledHookTurn(session); + if (!session.lifecycle) await settleCancelledHookTurn(session); if (resume) { const abort = new AbortController(); session.active = { @@ -2086,6 +2126,127 @@ export function registerHostedHarnessSessionRoutes( void open(req, res, false); }); + app.use('/session/:id', async (req, res, next) => { + const session = sessions.get(req.params['id']); + if ( + session?.lifecycle && + req.method !== 'GET' && + !['/lifecycle', '/detach', '/heartbeat'].includes(req.path) + ) + return error(res, 409, 'hosted_lifecycle_operation_active'); + if ( + session && + req.method !== 'GET' && + !['/lifecycle', '/heartbeat'].includes(req.path) && + !session.lifecycle + ) { + try { + await session.stores!.authorizeOrdinary( + req.method === 'DELETE' && req.path === '/' + ? 'legacy-close' + : undefined, + ); + } catch { + return error(res, 409, 'hosted_lifecycle_operation_active'); + } + } + next(); + }); + + app.post('/session/:id/lifecycle', async (req, res) => { + const session = identity(req, sessions); + if (!session) return error(res, 404, 'hosted_session_not_found'); + const body = object(req.body); + let authority: ManagedSessionLifecycleAuthority | undefined; + try { + authority = lifecycleAuthority(body?.['authority']); + } catch { + return error(res, 400, 'invalid_hosted_lifecycle_authority'); + } + const kind = body?.['kind']; + if ( + !authority || + (kind !== 'close' && kind !== 'delete') || + !isDeepStrictEqual( + body?.['sessionKey'], + session.managed.authority.sessionHeader.sessionKey, + ) || + session.toolProfile !== HOSTED_WORKSPACE_FILE_PROFILE + ) + return error(res, 400, 'invalid_hosted_lifecycle_request'); + if ( + session.lifecycle && + (session.lifecycle.operationId !== authority.operationId || + (session.lifecycleKind && session.lifecycleKind !== kind)) + ) + return error(res, 409, 'hosted_lifecycle_operation_conflict'); + if ( + session.active || + session.mcpBusy || + session.mcpRecovering || + session.hooksBusy + ) + return error(res, 409, 'hosted_turn_active'); + const previousAuthority = session.lifecycle; + session.stores!.setLifecycleAuthority(authority); + session.hooksBusy = true; + let authorized = false; + try { + await session.stores!.authorizeLifecycle(kind); + authorized = true; + session.lifecycle = authority; + session.lifecycleKind = kind; + session.mcpClosing = true; + const events = + kind === 'close' + ? [HookEventName.SessionEnd] + : [HookEventName.SessionEnd, HookEventName.SessionDelete]; + const occurrences = events.map((event) => + hostedHookOccurrenceId(event, authority.operationId), + ); + await session.hooks?.drain(new Set(occurrences)); + const effects = []; + if (session.hooks) { + for (const event of events) { + await runHostedLifecycleHook( + session, + event, + authority.operationId, + event === HookEventName.SessionEnd + ? { reason: 'other' } + : { deleted_session_id: req.params['id'] }, + ); + await session.hooks.settleOccurrence( + hostedHookOccurrenceId(event, authority.operationId), + ); + const entry = session.managed.authority.extensionRecord( + 'hook_execution', + hostedHookOccurrenceId(event, authority.operationId), + ); + if (!entry || !parseHookExecution(entry.record).resultRef) + throw new HostedHookRecoveryRequiredError(); + effects.push({ event, recordRef: entry.recordRef }); + } + } + res.json({ + protocolVersion: 1, + sessionKey: session.managed.authority.sessionHeader.sessionKey, + operationId: authority.operationId, + kind, + definitionRef: session.managed.authority.sessionHeader.definitionRef, + effects, + }); + } catch (cause) { + writeStderrLineSafe( + `qwen serve: Hosted lifecycle requires recovery: ${String(cause)}`, + ); + error(res, 503, 'hosted_lifecycle_recovery_required'); + } finally { + if (!authorized) session.stores!.setLifecycleAuthority(previousAuthority); + session.hooksBusy = false; + } + }); + app.post('/session/:id/prompt', (req, res) => { const session = identity(req, sessions); if (!session) return error(res, 404, 'hosted_session_not_found'); @@ -2380,7 +2541,7 @@ export function registerHostedHarnessSessionRoutes( .then(async (execution) => { if (execution.hookId !== '__plan__') await session.hooks!.status(execution.occurrenceId); - await settleCancelledHookTurn(session); + if (!session.lifecycle) await settleCancelledHookTurn(session); res.json({ operationId: execution.hookExecutionId, state: execution.run.state, @@ -3514,6 +3675,19 @@ export function registerHostedHarnessSessionRoutes( session.mcpBusy = true; session.mcpClosing = true; try { + if (session.lifecycle) { + if (req.method === 'DELETE') + return error(res, 409, 'hosted_lifecycle_operation_active'); + const authority = lifecycleAuthority(object(req.body)?.['authority']); + if ( + !authority || + authority.operationId !== session.lifecycle.operationId + ) + return error(res, 409, 'hosted_lifecycle_operation_conflict'); + session.lifecycle = authority; + session.stores!.setLifecycleAuthority(authority); + await session.stores!.authorizeLifecycle(); + } if (req.method === 'DELETE' && session.hooks) { session.hooksBusy = true; try { @@ -3549,7 +3723,7 @@ export function registerHostedHarnessSessionRoutes( ); error(res, 503, 'managed_session_close_failed'); } finally { - session.mcpClosing = false; + session.mcpClosing = !!session.lifecycle; session.mcpBusy = false; } }; diff --git a/packages/cli/src/serve/hosted-hook-session.ts b/packages/cli/src/serve/hosted-hook-session.ts index bbce249ab19..b4cd6569201 100644 --- a/packages/cli/src/serve/hosted-hook-session.ts +++ b/packages/cli/src/serve/hosted-hook-session.ts @@ -761,6 +761,7 @@ export class HostedHookSession { await this.commit('hook_execution', occurrenceId, marker); } if (marker.run.execution === 'intent') { + await this.broker.authorizeLifecycle(); marker = { ...marker, run: { ...marker.run, state: 'running', execution: 'dispatch_started' }, @@ -926,6 +927,7 @@ export class HostedHookSession { return cancelBeforeDispatch(); if (hook.config.type === HookType.Prompt && !prompt) throw new Error('Hook model activation is unavailable.'); + await this.broker.authorizeLifecycle(); record = { ...record, run: { @@ -1115,7 +1117,10 @@ export class HostedHookSession { this.recoveredBrokers.set(record.runtimeSessionId, broker); } if (current && !reacquire) await this.acquire(); - else await broker.acquire(); + else + await broker.acquire( + reacquire && record.run.runtime ? record.run.runtime : undefined, + ); if ( !broker.runtime || this.executions().some( @@ -1273,6 +1278,19 @@ export class HostedHookSession { return this.children.byOccurrence.get(occurrenceId) ?? []; } + async settleOccurrence(occurrenceId: string): Promise { + const marker = await this.status(occurrenceId); + if ( + !marker.resultRef || + this.childrenOf(occurrenceId).some( + (child) => + child.run.state === 'recovery_blocked' || + (!child.resultRef && child.run.execution !== 'not_started_proven'), + ) + ) + throw new HostedHookRecoveryRequiredError(); + } + async status(id: string, cancel = false): Promise { const direct = this.session.authority.extensionRecord('hook_execution', id); const matches = direct @@ -1498,8 +1516,16 @@ export class HostedHookSession { : output; } - async drain(): Promise { - for (const record of this.executions()) { + async drain( + excludedOccurrences: ReadonlySet = new Set(), + ): Promise { + const prior = () => + this.executions().filter( + (record) => + !excludedOccurrences.has(record.hookExecutionId) && + !excludedOccurrences.has(record.occurrenceId), + ); + for (const record of prior()) { if (!record.resultRef) await this.status(record.hookExecutionId, true); } await Promise.allSettled( @@ -1508,7 +1534,7 @@ export class HostedHookSession { await Promise.allSettled( [...this.occurrences.values()].map((occurrence) => occurrence.result), ); - for (const record of this.executions()) { + for (const record of prior()) { const latest = await this.status(record.hookExecutionId, true); if (!latest.resultRef && latest.run.execution !== 'not_started_proven') throw new HostedHookRecoveryRequiredError(); diff --git a/packages/cli/src/serve/hosted-workspace-broker.ts b/packages/cli/src/serve/hosted-workspace-broker.ts index 2801d61bfff..cb331d47179 100644 --- a/packages/cli/src/serve/hosted-workspace-broker.ts +++ b/packages/cli/src/serve/hosted-workspace-broker.ts @@ -29,10 +29,12 @@ import type { ManagedHookControl, ManagedHookOperationView, } from '@qwen-code/qwen-code-core/managed-runtime/managed-hook-protocol.js'; +import type { ManagedSessionLifecycleAuthority } from '@qwen-code/qwen-code-core/managed-runtime/http-managed-session-store.js'; export interface HostedWorkspaceBrokerOptions { baseUrl: string; token: string; + lifecycleAuthority?: () => ManagedSessionLifecycleAuthority | undefined; } function object(value: unknown): Record { @@ -124,9 +126,23 @@ export class HostedWorkspaceBroker { await this.request('/runtimes:warm', {}); } - async acquire(): Promise { + async authorizeLifecycle(): Promise { + if (this.options.lifecycleAuthority?.()) + await this.request('/runtimes:authorize-lifecycle', {}); + } + + async acquire(expected?: { + runtimeBindingId: string; + generation: string; + }): Promise { const response = await this.request('/tool-sessions:acquire', { turnKind: 'bootstrap', + ...(expected + ? { + recoveryBindingId: expected.runtimeBindingId, + recoveryGeneration: expected.generation, + } + : {}), }); const scope = object(response['scope']); if ( @@ -595,11 +611,20 @@ export class HostedWorkspaceBroker { if (!body) for (const [key, value] of Object.entries(fields)) url.searchParams.set(key, String(value)); + const authority = this.options.lifecycleAuthority?.(); const response = await fetch(url, { method: body ? 'POST' : 'GET', headers: { Authorization: `Bearer ${this.options.token}`, 'Content-Type': 'application/json', + ...(authority + ? { + 'X-Qwen-Lifecycle-Operation-Id': authority.operationId, + 'X-Qwen-Lifecycle-Claim-Generation': String( + authority.claimGeneration, + ), + } + : {}), }, ...(body ? { body: JSON.stringify(fields) } : {}), redirect: 'error', diff --git a/packages/cli/src/serve/types.ts b/packages/cli/src/serve/types.ts index 835d9e04426..fd06d2e53aa 100644 --- a/packages/cli/src/serve/types.ts +++ b/packages/cli/src/serve/types.ts @@ -452,6 +452,7 @@ export interface ServeOptions { * `v` is the wire schema version; bumped only on breaking frame changes. */ export interface HostedHarnessCapabilities { + readonly lifecycleProtocolVersion?: 1; readonly protocolVersions: { readonly current: 1; readonly supported: readonly [1]; diff --git a/packages/core/src/managed-runtime/http-managed-session-store.test.ts b/packages/core/src/managed-runtime/http-managed-session-store.test.ts index 454c3a2f2cc..b449eb9c592 100644 --- a/packages/core/src/managed-runtime/http-managed-session-store.test.ts +++ b/packages/core/src/managed-runtime/http-managed-session-store.test.ts @@ -161,6 +161,69 @@ describe('HTTP Managed Session store', () => { ).not.toThrow(); }); + it('scopes lifecycle authorization to the current claim and preserves writer identity', async () => { + const server = new FakeManagedSessionStore(); + const observed: Array<{ + path: string; + claim: string | null; + operation: string | null; + }> = []; + const stores = createHttpManagedSessionStores({ + baseUrl: 'http://session-store.test', + sessionKey: SESSION_KEY, + writerId: 'harness-a', + writerToken: TOKEN_A, + fetchFn: async (input, init) => { + const url = new URL(requestUrl(input)); + if (!url.pathname.endsWith(':authorize')) + return server.fetch(input, init); + const headers = new Headers(init?.headers); + expect(headers.get('X-Qwen-Managed-Writer-Token')).toBe(TOKEN_A); + expect(JSON.parse(String(init?.body))).toMatchObject({ + workspaceId: SESSION_KEY.workspaceId, + writerId: 'harness-a', + writerGeneration: 1, + }); + observed.push({ + path: url.pathname.split('/').at(-1)!, + claim: headers.get('X-Qwen-Lifecycle-Claim-Generation'), + operation: headers.get('X-Qwen-Lifecycle-Operation-Id'), + }); + return jsonResponse({}); + }, + }); + try { + await stores.journalStore.open({ sessionKey: SESSION_KEY }); + stores.setLifecycleAuthority({ + operationId: 'delete-original', + claimGeneration: 1, + }); + await stores.authorizeLifecycle('delete'); + stores.setLifecycleAuthority({ + operationId: 'delete-original', + claimGeneration: 2, + }); + await stores.authorizeLifecycle(); + stores.setLifecycleAuthority(); + await stores.authorizeOrdinary(); + expect(observed).toEqual([ + { + path: 'lifecycle:authorize', + operation: 'delete-original', + claim: '1', + }, + { + path: 'lifecycle:authorize', + operation: 'delete-original', + claim: '2', + }, + { path: 'execution:authorize', operation: null, claim: null }, + ]); + } finally { + await stores.close(); + } + }); + it('publishes bounded tool output immediately under the original writer grant', async () => { const server = new FakeManagedSessionStore(); let publication: Record | undefined; @@ -368,6 +431,9 @@ describe('HTTP Managed Session store', () => { 'exhausted-503', 'changed-receipt', 'invalid-json', + 'lifecycle-403', + 'lifecycle-409', + 'uncertain-lifecycle-403', ])( 'preserves activation transaction identity and failure fencing after %s', async (failure) => { @@ -381,6 +447,8 @@ describe('HTTP Managed Session store', () => { failure === 'uncommitted-503' || failure === 'lost-commit-response' || failure === 'lost-response-body'; + const refused = + failure === 'lifecycle-403' || failure === 'lifecycle-409'; let armed = false; let replay: unknown; const stores = createHttpManagedSessionStores({ @@ -396,6 +464,23 @@ describe('HTTP Managed Session store', () => { >; if (body['operation'] === 'installActivation') { requests.push(String(init?.body)); + if (refused || failure === 'uncertain-lifecycle-403') + return jsonResponse( + { + error: { + message: 'Lifecycle authorization revoked', + code: + failure === 'lifecycle-409' + ? 'workspace_lifecycle_authorization_revoked' + : 'workspace_access_denied', + }, + }, + failure === 'uncertain-lifecycle-403' && requests.length === 1 + ? 503 + : failure === 'lifecycle-409' + ? 409 + : 403, + ); if (failure === 'permanent-409' || failure === 'exhausted-503') return jsonResponse( { error: { code: 'commit_rejected' } }, @@ -460,6 +545,11 @@ describe('HTTP Managed Session store', () => { create: { definitionRef, rootSnapshotRef, createdBy: 'test' }, }); try { + if (refused || failure === 'uncertain-lifecycle-403') + stores.setLifecycleAuthority({ + operationId: 'lifecycle', + claimGeneration: 1, + }); armed = true; const controller = new ManagedHookActivationController(session); const run = vi.fn(async () => 'completed'); @@ -497,16 +587,44 @@ describe('HTTP Managed Session store', () => { await expect( controller.runTurn('next-turn', async () => 'accepted'), ).resolves.toBe('accepted'); + } else if (refused) { + await expect(operation).rejects.toThrow(); + expect(run).not.toHaveBeenCalled(); + expect(requests).toHaveLength(2); + expect(JSON.parse(requests[1])).toMatchObject({ + firstSequence: JSON.parse(requests[0]).firstSequence, + previousCommitDigest: JSON.parse(requests[0]).previousCommitDigest, + }); + expect(session.authority.writesStopped).toBe(false); + armed = false; + await expect( + controller.runHookOperation( + { + operationId: 'notification-retry', + occurrenceId: 'notification-retry', + originTurnId: null, + }, + run, + ), + ).resolves.toBe('completed'); + expect(run).toHaveBeenCalledOnce(); } else { await expect(operation).rejects.toThrow('writes stopped'); expect(run).not.toHaveBeenCalled(); - expect(requests).toHaveLength(failure === 'exhausted-503' ? 3 : 1); + expect(requests).toHaveLength( + failure === 'exhausted-503' + ? 3 + : failure === 'uncertain-lifecycle-403' + ? 2 + : 1, + ); expect(session.authority.writesStopped).toBe(true); await expect(controller.runTurn('next-turn', run)).rejects.toThrow( 'current Session activation', ); } - expect(requests.every((body) => body === requests[0])).toBe(true); + if (!refused) + expect(requests.every((body) => body === requests[0])).toBe(true); } finally { await session.close(); } diff --git a/packages/core/src/managed-runtime/http-managed-session-store.ts b/packages/core/src/managed-runtime/http-managed-session-store.ts index b9442149a1c..8c0aa2164f0 100644 --- a/packages/core/src/managed-runtime/http-managed-session-store.ts +++ b/packages/core/src/managed-runtime/http-managed-session-store.ts @@ -32,6 +32,7 @@ import { type DurableToolResultResourceStore, } from './resource-tool-result-store.js'; import { + ManagedSessionCommitRejectedError, scanManagedSessionJournal, type ManagedSessionJournalHandle, type ManagedSessionJournalScan, @@ -86,6 +87,11 @@ export interface HttpManagedSessionStoreOptions { readonly allowInsecureHttp?: boolean; } +export interface ManagedSessionLifecycleAuthority { + readonly operationId: string; + readonly claimGeneration: number; +} + export interface HttpManagedSessionStores { readonly journalStore: ManagedSessionJournalStore; readonly resourceStore: ManagedSessionResourceStore; @@ -93,6 +99,9 @@ export interface HttpManagedSessionStores { readonly toolResultResources: DurableToolResultResourceStore; assertWritable(): Promise; close(): Promise; + setLifecycleAuthority(authority?: ManagedSessionLifecycleAuthority): void; + authorizeLifecycle(kind?: 'close' | 'delete'): Promise; + authorizeOrdinary(kind?: 'legacy-close'): Promise; } export interface HttpToolPublicationOwner { @@ -141,6 +150,10 @@ export function createHttpManagedSessionStores( }, assertWritable: () => client.assertWritable(), close: () => client.seal(), + setLifecycleAuthority: (authority) => + client.setLifecycleAuthority(authority), + authorizeLifecycle: (kind) => client.authorizeLifecycle(kind), + authorizeOrdinary: (kind) => client.authorizeOrdinary(kind), }; } @@ -399,6 +412,31 @@ class ManagedSessionStoreHttpClient { private renewPromise: Promise | undefined; private renewTimer: NodeJS.Timeout | undefined; private sealed = false; + private lifecycleAuthority?: ManagedSessionLifecycleAuthority; + + setLifecycleAuthority(authority?: ManagedSessionLifecycleAuthority): void { + this.lifecycleAuthority = authority; + } + + async authorizeOrdinary(kind?: 'legacy-close'): Promise { + await this.assertWritable(); + await this.json('/execution:authorize', 'POST', { + ...(kind ? { kind } : {}), + workspaceId: this.sessionKey.workspaceId, + writerId: this.writerId, + writerGeneration: this.grant!.writerGeneration, + }); + } + + async authorizeLifecycle(kind?: 'close' | 'delete'): Promise { + await this.assertWritable(); + await this.json('/lifecycle:authorize', 'POST', { + ...(kind ? { kind } : {}), + workspaceId: this.sessionKey.workspaceId, + writerId: this.writerId, + writerGeneration: this.grant!.writerGeneration, + }); + } private readonly publicationAdmissions = new Map(); constructor( @@ -619,6 +657,16 @@ class ManagedSessionStoreHttpClient { ); break; } catch (error) { + if ( + attempt === 0 && + this.lifecycleAuthority && + error instanceof ManagedSessionStoreHttpError && + (error.status === 403 || + (error.status === 409 && + (error.remoteCode.startsWith('workspace_lifecycle_') || + error.remoteCode === 'workspace_unavailable'))) + ) + throw new ManagedSessionCommitRejectedError(error); const uncertain = (error instanceof ManagedSessionStoreHttpError && (error.status === 429 || error.status >= 500)) || @@ -1093,6 +1141,15 @@ class ManagedSessionStoreHttpClient { this.sessionKey.tenantId, [HTTP_MANAGED_SESSION_STORE_CONTRACT.writerTokenHeader]: this.writerToken, + ...(this.lifecycleAuthority + ? { + 'X-Qwen-Lifecycle-Operation-Id': + this.lifecycleAuthority.operationId, + 'X-Qwen-Lifecycle-Claim-Generation': String( + this.lifecycleAuthority.claimGeneration, + ), + } + : {}), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), diff --git a/packages/core/src/managed-runtime/managed-session-authority.ts b/packages/core/src/managed-runtime/managed-session-authority.ts index 463cf9141ad..4c1f1602cef 100644 --- a/packages/core/src/managed-runtime/managed-session-authority.ts +++ b/packages/core/src/managed-runtime/managed-session-authority.ts @@ -70,6 +70,7 @@ import { parseHookExecution, } from './managed-hook-record.js'; import { + ManagedSessionCommitRejectedError, managedSessionActivationStateFrom, managedSessionCommandKey, type ManagedSessionActivationState, @@ -2189,6 +2190,7 @@ export class LocalManagedSessionAuthority { await this.journal.appendTransaction(records); this.lastRecordUuid = final.uuid; } catch (cause) { + if (cause instanceof ManagedSessionCommitRejectedError) throw cause; // Records may already be on disk, so the sequences this transaction // claimed are spent whether or not the marker landed. this.writeFailure = diff --git a/packages/core/src/managed-runtime/managed-session-storage.ts b/packages/core/src/managed-runtime/managed-session-storage.ts index df54d849c32..c1c32251e1c 100644 --- a/packages/core/src/managed-runtime/managed-session-storage.ts +++ b/packages/core/src/managed-runtime/managed-session-storage.ts @@ -23,6 +23,15 @@ import { type ManagedSessionKey, } from './managed-session-records.js'; +/** A durable store refused the transaction without appending any records. */ +export class ManagedSessionCommitRejectedError extends ManagedSessionRecordError { + constructor(cause: Error) { + super(cause.message); + this.cause = cause; + this.name = 'ManagedSessionCommitRejectedError'; + } +} + export interface ManagedSessionCommitReceipt { readonly transactionId: string; readonly commandId: string; diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedSessionStoreController.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedSessionStoreController.java index ba12daec802..dc0023570e9 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedSessionStoreController.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedSessionStoreController.java @@ -41,14 +41,45 @@ public ManagedSessionStoreController(ManagedSessionStore store) { this.store = store; } + private static com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority lifecycleAuthority(HttpHeaders headers) { + String operation = headers.getFirst(com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority.OPERATION_HEADER); + String generation = headers.getFirst(com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority.GENERATION_HEADER); + if (operation == null && generation == null) { + return null; + } + try { + return new com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority(operation, Long.parseLong(generation)); + } catch (RuntimeException error) { + throw new ApiException(org.springframework.http.HttpStatus.BAD_REQUEST, "invalid_lifecycle_authority", "Invalid lifecycle authority."); + } + } + + @PostMapping("/execution:authorize") + public java.util.Map authorizeOrdinary(TenantContext tenant, @PathVariable String sessionId, + @RequestHeader(ManagedSessionStoreModels.WRITER_TOKEN_HEADER) String writerToken, + @Valid @RequestBody ManagedSessionStoreModels.AuthorizeLifecycleRequest request) { + store.authorizeOrdinary(tenant.tenantId(), sessionId, writerToken, request); + return java.util.Map.of("authorized", true); + } + + @PostMapping("/lifecycle:authorize") + public java.util.Map authorizeLifecycle(TenantContext tenant, @PathVariable String sessionId, + @RequestHeader(ManagedSessionStoreModels.WRITER_TOKEN_HEADER) String writerToken, + @Valid @RequestBody ManagedSessionStoreModels.AuthorizeLifecycleRequest request, + @RequestHeader HttpHeaders headers) { + store.authorizeLifecycle(tenant.tenantId(), sessionId, writerToken, request, lifecycleAuthority(headers)); + return java.util.Map.of("authorized", true); + } + @PostMapping("/writers:acquire") public WriterGrant acquire(TenantContext tenant, @PathVariable String sessionId, @RequestHeader(ManagedSessionStoreModels.WRITER_TOKEN_HEADER) String writerToken, - @Valid @RequestBody AcquireWriterRequest request) { + @Valid @RequestBody AcquireWriterRequest request, + @RequestHeader HttpHeaders headers) { return store.acquireWriter(tenant.tenantId(), sessionId, - writerToken, request); + writerToken, request, lifecycleAuthority(headers)); } @PostMapping("/writers:renew") @@ -56,9 +87,10 @@ public WriterGrant renew(TenantContext tenant, @PathVariable String sessionId, @RequestHeader(ManagedSessionStoreModels.WRITER_TOKEN_HEADER) String writerToken, - @Valid @RequestBody RenewWriterRequest request) { + @Valid @RequestBody RenewWriterRequest request, + @RequestHeader HttpHeaders headers) { return store.renewWriter(tenant.tenantId(), sessionId, - writerToken, request); + writerToken, request, lifecycleAuthority(headers)); } @PostMapping("/writers:seal") @@ -66,9 +98,10 @@ public SealReceipt seal(TenantContext tenant, @PathVariable String sessionId, @RequestHeader(ManagedSessionStoreModels.WRITER_TOKEN_HEADER) String writerToken, - @Valid @RequestBody SealWriterRequest request) { + @Valid @RequestBody SealWriterRequest request, + @RequestHeader HttpHeaders headers) { return store.sealWriter(tenant.tenantId(), sessionId, - writerToken, request); + writerToken, request, lifecycleAuthority(headers)); } @PostMapping("/recovery:block") @@ -86,9 +119,10 @@ public CommitReceipt commit(TenantContext tenant, @PathVariable String sessionId, @RequestHeader(ManagedSessionStoreModels.WRITER_TOKEN_HEADER) String writerToken, - @Valid @RequestBody CommitTransactionRequest request) { + @Valid @RequestBody CommitTransactionRequest request, + @RequestHeader HttpHeaders headers) { return store.commit(tenant.tenantId(), sessionId, - writerToken, request); + writerToken, request, lifecycleAuthority(headers)); } @GetMapping("/restore") diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessConnector.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessConnector.java index 2534f14839d..f2c361b35e0 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessConnector.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessConnector.java @@ -12,6 +12,16 @@ default boolean isWorkspaceFilesAvailable() { return false; } + default boolean supportsLifecycle() { return false; } + + default JsonNode settleLifecycle(com.alibaba.qwen.code.managedagent.store.StoreModels.OperationRecord operation) { + throw new UnsupportedOperationException("Hosted lifecycle is unavailable"); + } + + default void detachLifecycle(com.alibaba.qwen.code.managedagent.store.StoreModels.OperationRecord operation) { + throw new UnsupportedOperationException("Hosted lifecycle is unavailable"); + } + Attachment createOrLoad(String tenantId, String sessionId, boolean loadExisting); diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java index af5ddc3ca9a..a7c16b66616 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java @@ -103,6 +103,54 @@ public boolean isAvailable() { return true; } + @Override + public boolean supportsLifecycle() { + if (!isWorkspaceFilesAvailable()) { + return false; + } + try { + return client().capabilities().getLifecycleProtocolVersion() == 1; + } catch (com.alibaba.qwen.code.daemon.DaemonException unavailable) { + return false; + } + } + + @Override + public com.fasterxml.jackson.databind.JsonNode settleLifecycle( + com.alibaba.qwen.code.managedagent.store.StoreModels.OperationRecord operation) { + SessionRecord session = sessions.requireSession(operation.tenantId(), operation.sessionId()); + AttachmentKey key = new AttachmentKey(operation.tenantId(), operation.sessionId()); + HarnessSessionRef ref = attachments.get(key); + if (ref == null) { + ref = client().loadSession(new LoadHarnessSession(session.sessionId(), managedSessionStore(session), + false, toolProfile(session), false).forLifecycle(operation.operationId(), operation.claimGeneration())); + attachments.put(key, ref); + } + var authority = Map.of("operationId", operation.operationId(), "claimGeneration", operation.claimGeneration()); + var request = Map.of("sessionKey", Map.of("tenantId", session.tenantId(), + "workspaceId", session.workspace().getWorkspaceId(), "sessionId", session.sessionId()), + "kind", operation.kind().name().toLowerCase(java.util.Locale.ROOT), "authority", authority); + return new com.fasterxml.jackson.databind.ObjectMapper().valueToTree(client().settleLifecycle(ref, request)); + } + + @Override + public void detachLifecycle(com.alibaba.qwen.code.managedagent.store.StoreModels.OperationRecord operation) { + AttachmentKey key = new AttachmentKey(operation.tenantId(), operation.sessionId()); + HarnessSessionRef ref = attachments.get(key); + if (ref == null) { + return; + } + try { + client().detachLifecycle(ref, Map.of("operationId", operation.operationId(), "claimGeneration", operation.claimGeneration())); + } catch (DaemonHttpException error) { + if (error.getStatusCode() != 404) { + throw error; + } + } + attachments.remove(key); + pendingRecovery.remove(key); + } + @Override public boolean isWorkspaceFilesAvailable() { return properties.isWorkspaceFilesEnabled(); diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/EmbeddedRuntimeBroker.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/EmbeddedRuntimeBroker.java index bd05c9aeee7..c1010889bfc 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/EmbeddedRuntimeBroker.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/EmbeddedRuntimeBroker.java @@ -106,32 +106,41 @@ public EmbeddedRuntimeBroker(AgentStateStore store, RuntimeProvisioner baseProvisioner = provisioner(broker, http); RuntimeProvisioner provisioner = workspaces == null ? baseProvisioner : new WorkspaceRuntimeProvisioner(baseProvisioner, workspaces, workspaceExecutionStore); - HarnessSessionResolver resolver = sessionId -> { - SessionRecord session = store.findSessionById(sessionId) - .orElse(null); - if (session == null) { - CompletableFuture failed = - new CompletableFuture<>(); - failed.completeExceptionally(new IllegalArgumentException( - "Session is not owned by this service")); - return failed; + HarnessSessionResolver resolver = new HarnessSessionResolver() { + @Override + public CompletionStage resolve(String sessionId) { + return resolve(sessionId, null); } - if (session.workspace() != null) { - if (workspaces != null) { - return CompletableFuture.completedFuture(workspaces.resolve(sessionId).scope()); + + @Override + public CompletionStage resolve(String sessionId, + com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { + SessionRecord session = store.findSessionById(sessionId) + .orElse(null); + if (session == null) { + CompletableFuture failed = + new CompletableFuture<>(); + failed.completeExceptionally(new IllegalArgumentException( + "Session is not owned by this service")); + return failed; + } + if (session.workspace() != null) { + if (workspaces != null) { + return CompletableFuture.completedFuture(workspaces.resolve(sessionId, authority).scope()); + } + return CompletableFuture.failedFuture( + new RuntimeBrokerException(409, + "workspace_unavailable", + "Hosted Workspace execution is not available.", + false)); } - return CompletableFuture.failedFuture( - new RuntimeBrokerException(409, - "workspace_unavailable", - "Hosted Workspace execution is not available.", - false)); + return CompletableFuture.completedFuture(new RuntimeScope( + session.tenantId(), workspaceId, + broker.getWorkspaceGeneration(), + workspaceCwd, + properties.getHarness().getCapabilityDigest(), + broker.getIsolationClass())); } - return CompletableFuture.completedFuture(new RuntimeScope( - session.tenantId(), workspaceId, - broker.getWorkspaceGeneration(), - workspaceCwd, - properties.getHarness().getCapabilityDigest(), - broker.getIsolationClass())); }; ObjectMapper mapper = new ObjectMapper(); RuntimePublicationVerifier verifier = publications == null || publicationData == null diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java index efceefeef5b..cce6bf01ab5 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java @@ -96,7 +96,8 @@ void setRuntimeWarmer(RuntimeWarmer runtimeWarmer) { private boolean supportsClose(SessionRecord session) { return session.workspace() == null || store.workspaceFilesEnabled() - && runtimeWarmer != null && runtimeWarmer.supportsWorkspaceClose(); + && runtimeWarmer != null && runtimeWarmer.supportsWorkspaceClose() + && harness.supportsLifecycle(); } private boolean hasActions(SessionRecord session) { @@ -604,7 +605,7 @@ private PublicSession publicSession(SessionRecord session, true, session.workspace() == null, true, - hasActions(session), supportsClose(session), retention, retention, retention), + hasActions(session), supportsClose(session), retention, retention, supportsDelete(session, retention)), publicWorkspace(session)); } @@ -673,7 +674,12 @@ private WebShellSession webShellSession(SessionRecord session, // Stage H records its Session store holds (H0c). new WebShellSessionCapabilities(true, hasArtifacts(session), hasActions(session), maySubmit, supportsClose(session), - retention, retention, retention)); + retention, retention, supportsDelete(session, retention))); + } + + private boolean supportsDelete(SessionRecord session, boolean retention) { + return retention || session.workspace() != null && store.workspaceFilesEnabled() + && runtimeWarmer != null && runtimeWarmer.supportsWorkspaceClose() && harness.supportsLifecycle(); } private static WebShellWorkspace webShellWorkspace(SessionRecord session) { diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/SessionLifecycleCoordinator.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/SessionLifecycleCoordinator.java index 4316f131e99..e751ec6f576 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/SessionLifecycleCoordinator.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/SessionLifecycleCoordinator.java @@ -32,6 +32,17 @@ public class SessionLifecycleCoordinator { SessionLifecycleCoordinator.class); private static final int SCAN_LIMIT = 50; private final AgentStateStore store; + private com.alibaba.qwen.code.managedagent.store.WorkspaceLifecycleStore lifecycle; + + @org.springframework.beans.factory.annotation.Autowired(required = false) + public void setWorkspaceLifecycleStore(com.alibaba.qwen.code.managedagent.store.WorkspaceLifecycleStore lifecycle) { + this.lifecycle = lifecycle; + } + + public boolean supportsWorkspaceLifecycle() { + return runtimeWarmer.supportsWorkspaceClose() && harness.supportsLifecycle(); + } + private final ManagedSessionStore sessionStore; private final HarnessConnector harness; private final RuntimeWarmer runtimeWarmer; @@ -142,6 +153,15 @@ private void deliver(String tenantId, String sessionId, blocked = "workspace_close_identity_unverified"; } } + if (cause instanceof com.alibaba.qwen.code.managedagent.api.ApiException apiError + && (apiError.getCode().startsWith("workspace_lifecycle") || apiError.getCode().startsWith("workspace_close"))) { + blocked = apiError.getCode(); + } + if ((cause instanceof com.alibaba.qwen.code.daemon.DaemonHttpException + || cause instanceof com.alibaba.qwen.code.daemon.MutationOutcomeUnknownException) + && operationIsLifecycle(claimed)) { + blocked = "workspace_lifecycle_hooks_unsettled"; + } if (valid.get() && blocked != null) { store.blockLifecycleOperation(tenantId, sessionId, operationId, owner, claimed.claimGeneration(), blocked, Math.addExact(clock.millis(), delay)); @@ -160,6 +180,10 @@ private void deliver(String tenantId, String sessionId, } } + private static boolean operationIsLifecycle(OperationRecord operation) { + return operation.lifecycleProtocolVersion() == 1; + } + // Returns whether the Harness that held the Session acknowledged closing // it. A Harness that never held it, or that replaced the one that did, // answers too, but its answer confirms nothing about the Session. @@ -170,6 +194,23 @@ private boolean settle(OperationRecord operation) { && ("CLOSED".equals(operation.sessionStatusBefore()) || "ARCHIVED".equals(operation.sessionStatusBefore()))) { return false; } + if (bound && operation.lifecycleProtocolVersion() == 1) { + if (lifecycle == null || !runtimeWarmer.supportsWorkspaceClose()) { + throw com.alibaba.qwen.code.managedagent.store.WorkspaceLifecycleStore.blocked("workspace_close_identity_unverified"); + } + if (lifecycle.recoverEffects(operation) == null) { + if (!harness.supportsLifecycle()) { + throw com.alibaba.qwen.code.managedagent.store.WorkspaceLifecycleStore.blocked("workspace_lifecycle_protocol_unavailable"); + } + lifecycle.saveEffects(operation, harness.settleLifecycle(operation)); + } + harness.detachLifecycle(operation); + if (sessionStore.hasLiveWriter(operation.tenantId(), operation.sessionId())) { + throw com.alibaba.qwen.code.managedagent.store.WorkspaceLifecycleStore.blocked("workspace_lifecycle_writer_active"); + } + runtimeWarmer.closeWorkspace(operation.tenantId(), operation.sessionId()).toCompletableFuture().join(); + return true; + } if (bound) { if (!runtimeWarmer.supportsWorkspaceClose()) { throw new com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException(409, diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/SessionLifecycleService.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/SessionLifecycleService.java index 9c4ce06050a..498f942f239 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/SessionLifecycleService.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/SessionLifecycleService.java @@ -91,8 +91,11 @@ private OperationAdmission admit(String tenantId, String actorId, String digest = sessions.lifecycleDigest(sessionId, DIGEST_NAMES.get(kind)); OperationAdmission admission; if (session.workspace() != null) { + boolean activeLifecycle = "ACTIVE".equals(session.status()) && (kind == OperationKind.CLOSE || kind == OperationKind.DELETE); admission = store.beginWorkspaceLifecycle(tenantId, sessionId, kind, actorId, actorDigest(actorId), - idempotencyKey, digest, kind == OperationKind.CLOSE && runtimeWarmer != null && runtimeWarmer.supportsWorkspaceClose()); + idempotencyKey, digest, activeLifecycle ? coordinator.supportsWorkspaceLifecycle() + : kind == OperationKind.CLOSE && runtimeWarmer != null && runtimeWarmer.supportsWorkspaceClose(), + activeLifecycle ? 1 : 0); } else { sessions.requireLegacyWorkspace(tenantId, actorId, sessionId); admission = store.beginOperation(tenantId, sessionId, kind, actorDigest(actorId), idempotencyKey, digest); diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/WorkspaceRuntimeProvisioner.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/WorkspaceRuntimeProvisioner.java index ba6e378dd92..48cbcf42c40 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/WorkspaceRuntimeProvisioner.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/WorkspaceRuntimeProvisioner.java @@ -30,7 +30,7 @@ public RuntimeProvisionRequest createRequest(RuntimeScope scope, String isolatio if (!WorkspaceExecutionProfile.CAPABILITY_DIGEST.equals(scope.getCapabilityDigest())) { return delegate.createRequest(scope, isolationKey); } - var resolved = resolver.resolve(isolationKey); + var resolved = resolver.resolve(isolationKey, scope.getLifecycleAuthority()); if (!resolved.scope().equals(scope)) { throw WorkspaceExecutionStore.unavailable(); } @@ -65,7 +65,7 @@ private void requireReady(RuntimeProvisionRequest request) { if (!request.isManagedContext() || !executionStore.verifiedRecoveryEnabled()) { return; } - var resolved = resolver.resolve(request.getIsolationKey()); + var resolved = resolver.resolve(request.getIsolationKey(), request.getScope().getLifecycleAuthority()); if (!resolved.scope().equals(request.getScope()) || !resolved.binding().getStorageId().equals(request.getStorageId())) { throw WorkspaceExecutionStore.unavailable(); diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/WorkspaceRuntimeResolver.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/WorkspaceRuntimeResolver.java index 8ac023b8c9f..4ae64a93b10 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/WorkspaceRuntimeResolver.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/WorkspaceRuntimeResolver.java @@ -59,9 +59,27 @@ final class WorkspaceRuntimeResolver { } Resolved resolve(String sessionId) { + return resolve(sessionId, null); + } + + Resolved resolve(String sessionId, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority lifecycle) { + return resolve(sessionId, lifecycle, false); + } + + Resolved resolveHook(String sessionId, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority lifecycle) { + return resolve(sessionId, lifecycle, true); + } + + private Resolved resolve(String sessionId, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority lifecycle, boolean hook) { SessionRecord session = sessions.findSessionById(sessionId) .orElseThrow(WorkspaceExecutionStore::unavailable); - authority.authorize(session); + if (lifecycle == null && hook && "CLOSING".equals(session.status())) { + authority.authorizeLegacyClose(session); + } else if (lifecycle == null) { + authority.authorize(session); + } else { + authority.authorizeLifecycle(session, lifecycle); + } ContextBinding binding = session.workspace(); Mount mount = mounts.get(new Storage(binding.getTenantId(), binding.getStorageId())); if (mount == null) { @@ -78,7 +96,7 @@ Resolved resolve(String sessionId) { } return new Resolved(binding, new RuntimeScope(session.tenantId(), binding.getWorkspaceId(), Long.toString(binding.getWorkspaceGeneration()), mount.root().toString(), - WorkspaceExecutionProfile.CAPABILITY_DIGEST, "session")); + WorkspaceExecutionProfile.CAPABILITY_DIGEST, "session").withLifecycleAuthority(lifecycle)); } ContextBinding savedBinding(String sessionId) { diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/WorkspaceRuntimeTransport.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/WorkspaceRuntimeTransport.java index 36b88853421..2eb12c17301 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/WorkspaceRuntimeTransport.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/WorkspaceRuntimeTransport.java @@ -288,7 +288,7 @@ public CompletionStage control(RuntimeLease lease, RuntimeSession sessio ManagedMcpProtocol.validateSession(session, operation); } boolean recovery = ManagedMcpProtocol.isRecovery(operation) || ManagedHookProtocol.isRecovery(operation); - Context context = context(lease, session, !recovery); + Context context = context(lease, session, !recovery, ManagedHookProtocol.isOperation(operation)); if (context.runtime().getState() != RuntimeBindingRecord.State.READY && !(recovery && context.runtime().getState() == RuntimeBindingRecord.State.DRAINING)) { throw WorkspaceExecutionStore.unavailable(); @@ -333,9 +333,14 @@ public CompletionStage release(RuntimeLease lease, RuntimeSession sessi } private Context context(RuntimeLease lease, RuntimeSession session, boolean authorize) { + return context(lease, session, authorize, false); + } + + private Context context(RuntimeLease lease, RuntimeSession session, boolean authorize, boolean hook) { ContextBinding binding; if (authorize) { - var resolved = resolver.resolve(session.getHarnessSessionId()); + var resolved = hook ? resolver.resolveHook(session.getHarnessSessionId(), session.getScope().getLifecycleAuthority()) + : resolver.resolve(session.getHarnessSessionId(), session.getScope().getLifecycleAuthority()); if (!resolved.scope().equals(session.getScope())) { throw WorkspaceExecutionStore.unavailable(); } diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/AgentStateStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/AgentStateStore.java index 39f38500a47..8fc6ff827b3 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/AgentStateStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/AgentStateStore.java @@ -104,6 +104,14 @@ OperationAdmission beginWorkspaceLifecycle(String tenantId, String sessionId, OperationKind kind, String actorId, String actorDigest, String key, String digest, boolean closeSupported); + default OperationAdmission beginWorkspaceLifecycle(String tenantId, String sessionId, OperationKind kind, + String actorId, String actorDigest, String key, String digest, boolean supported, int protocolVersion) { + if (protocolVersion != 0) { + throw new UnsupportedOperationException("Workspace lifecycle protocol is unavailable"); + } + return beginWorkspaceLifecycle(tenantId, sessionId, kind, actorId, actorDigest, key, digest, supported); + } + boolean hasCompletedWorkspaceClose(String tenantId, String sessionId); /** The given Sessions with a completed workspace close, in one read. */ diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java index 8b5b2fc1587..1c43983eff3 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java @@ -90,6 +90,13 @@ public class ManagedAgentStore implements AgentStateStore { "turn_record." + String.join(", turn_record.", TURN_SUMMARY_COLUMNS.split(", ")); private final JdbcTemplate jdbc; + private WorkspaceLifecycleStore lifecycle; + + @org.springframework.beans.factory.annotation.Autowired(required = false) + public void setWorkspaceLifecycleStore(WorkspaceLifecycleStore lifecycle) { + this.lifecycle = lifecycle; + } + private final ObjectMapper objectMapper; private final Clock clock; private final String approvalMode; @@ -196,7 +203,7 @@ public class ManagedAgentStore implements AgentStateStore { result.getString("receipt_id"), result.getString("lease_owner"), result.getLong("claim_generation"), - result.getInt("attempt_count"), result.getString("error_code")); + result.getInt("attempt_count"), result.getString("error_code"), result.getInt("lifecycle_protocol_version")); private final RowMapper operationTargetMapper = (result, row) -> new OperationTarget( result.getString("tenant_id"), @@ -663,6 +670,13 @@ public OperationAdmission beginWorkspaceLifecycle(String tenantId, String sessio return beginLifecycle(tenantId, sessionId, kind, actorDigest, key, digest, actorId, closeSupported); } + @Override + @Transactional + public OperationAdmission beginWorkspaceLifecycle(String tenantId, String sessionId, + OperationKind kind, String actorId, String actorDigest, String key, String digest, boolean supported, int protocolVersion) { + return beginLifecycle(tenantId, sessionId, kind, actorDigest, key, digest, actorId, supported, protocolVersion); + } + @Override public boolean hasCompletedWorkspaceClose(String tenantId, String sessionId) { return completedWorkspaceCloses(tenantId, List.of(sessionId)) @@ -734,6 +748,15 @@ public SessionMutation unarchiveWorkspaceSession(String tenantId, String session private OperationAdmission beginLifecycle(String tenantId, String sessionId, OperationKind kind, String actorDigest, String idempotencyKey, String requestDigest, String actorId, boolean supported) { + return beginLifecycle(tenantId, sessionId, kind, actorDigest, idempotencyKey, requestDigest, actorId, supported, 0); + } + + private OperationAdmission beginLifecycle(String tenantId, String sessionId, OperationKind kind, + String actorDigest, String idempotencyKey, String requestDigest, String actorId, boolean supported, int protocolVersion) { + if (protocolVersion == 1) { + WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); + ToolPublicationRetentionStore.lockTenant(jdbc, tenantId); + } SessionRecord session = requireSessionForUpdate(tenantId, sessionId); if (session.workspace() != null) { requireWorkspaceCreator(session, actorId); @@ -760,7 +783,7 @@ private OperationAdmission beginLifecycle(String tenantId, String sessionId, Ope "The Session was not found."); } if (session.workspace() != null) { - if (kind == OperationKind.CLOSE) { + if (kind == OperationKind.CLOSE || kind == OperationKind.DELETE && "ACTIVE".equals(session.status()) && protocolVersion == 1) { if (!supported || !workspaceFilesEnabled) { throw workspaceExecutionUnavailable(); } @@ -781,6 +804,9 @@ private OperationAdmission beginLifecycle(String tenantId, String sessionId, Ope } requireNoOpenOperation(tenantId, sessionId); validateOperationStart(session, kind); + if (protocolVersion == 1) { + WorkspaceLifecycleStore.requireIdleJournal(jdbc, objectMapper, tenantId, sessionId); + } long now = lifecycleDatabaseTime(); String operationId = publicId("op"); Map data = Map.of("sessionId", sessionId, @@ -792,15 +818,21 @@ private OperationAdmission beginLifecycle(String tenantId, String sessionId, Ope + " actor_digest, idempotency_key, request_digest," + " state, admission_stage, delivery_state," + " session_status_before, receipt_id, available_at," - + " created_at, updated_at, completed_at) VALUES" + + " created_at, updated_at, completed_at, lifecycle_protocol_version) VALUES" + " (?, ?, ?, ?, ?, ?, ?, ?, 'JAVA_DURABLE', ?, ?, ?," - + " ?, ?, ?, ?)", + + " ?, ?, ?, ?, ?)", tenantId, sessionId, operationId, kind.name(), actorDigest, idempotencyKey, requestDigest, archive ? "COMPLETED" : "PENDING", archive ? "CONFIRMED" : "PENDING", session.status(), archive ? publicId("rcpt") : null, now, now, now, - archive ? now : null); + archive ? now : null, protocolVersion); + if (protocolVersion == 1) { + jdbc.execute((org.springframework.jdbc.core.ConnectionCallback) connection -> { + com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository.beginHarnessLifecycle(connection, tenantId, sessionId, operationId); + return null; + }); + } jdbc.update("UPDATE managed_agent_session SET status = ?," + " updated_at = ?, version = version + 1 WHERE" + " tenant_id = ? AND session_id = ?", @@ -834,7 +866,7 @@ public List findDeliverableOperations(long now, "SELECT tenant_id, session_id, operation_id FROM" + " managed_agent_operation WHERE operation_kind <>" + " 'ACTION_RESPONSE' AND (delivery_state = 'PENDING' OR (delivery_state = 'BLOCKED' AND (operation_kind = 'CLOSE'" - + " OR (operation_kind = 'DELETE' AND session_status_before IN ('CLOSED', 'ARCHIVED'))))) AND" + + " OR (operation_kind = 'DELETE' AND (session_status_before IN ('CLOSED', 'ARCHIVED') OR lifecycle_protocol_version = 1))))) AND" + " available_at <= ? ORDER BY available_at LIMIT ?", operationTargetMapper, now, @@ -858,6 +890,7 @@ public List findDeliverableOperations(long now, public Optional claimOperation(String tenantId, String sessionId, String operationId, String owner, Duration leaseDuration) { + WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); long now = lifecycleDatabaseTime(); int updated = jdbc.update("UPDATE managed_agent_operation SET state =" + " 'RUNNING', delivery_state = 'LEASED'," @@ -866,10 +899,13 @@ public Optional claimOperation(String tenantId, + " updated_at = ? WHERE tenant_id = ? AND" + " session_id = ? AND operation_id = ? AND" + " (((delivery_state = 'PENDING' OR (delivery_state = 'BLOCKED' AND (operation_kind = 'CLOSE'" - + " OR (operation_kind = 'DELETE' AND session_status_before IN ('CLOSED', 'ARCHIVED'))))) AND available_at <= ?)" + + " OR (operation_kind = 'DELETE' AND (session_status_before IN ('CLOSED', 'ARCHIVED') OR lifecycle_protocol_version = 1))))) AND available_at <= ?)" + " OR (delivery_state = 'LEASED' AND lease_until < ?))", owner, Math.addExact(now, leaseDuration.toMillis()), now, tenantId, sessionId, operationId, now, now); + if (updated == 1) { + syncLifecycleClaim(tenantId, sessionId, operationId); + } return updated == 1 ? findOperation(tenantId, sessionId, operationId) : Optional.empty(); } @@ -880,10 +916,17 @@ public boolean completeOperation(String tenantId, String sessionId, String operationId, String owner, long claimGeneration, boolean harnessConfirmed) { var target = findOperation(tenantId, sessionId, operationId).orElseThrow(); - if (target.kind() == OperationKind.DELETE) { + if (target.lifecycleProtocolVersion() == 1) { + WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); + ToolPublicationRetentionStore.lockTenant(jdbc, tenantId); + } + if (target.kind() == OperationKind.DELETE && target.lifecycleProtocolVersion() != 1) { ToolPublicationRetentionStore.lockDeletion(jdbc, tenantId, sessionId); } SessionRecord session = requireSessionForUpdate(tenantId, sessionId); + if (target.lifecycleProtocolVersion() == 1 && target.kind() == OperationKind.DELETE) { + ToolPublicationRetentionStore.lockDeletion(jdbc, tenantId, sessionId); + } OperationRecord operation = jdbc.query("SELECT * FROM" + " managed_agent_operation WHERE tenant_id = ? AND" + " session_id = ? AND operation_id = ? FOR UPDATE", @@ -904,9 +947,16 @@ public boolean completeOperation(String tenantId, String sessionId, + session.status() + " during its " + operation.kind() + " operation"); } + if (operation.lifecycleProtocolVersion() == 1) { + if (lifecycle == null) { + throw WorkspaceLifecycleStore.blocked("workspace_close_identity_unverified"); + } + lifecycle.verifyCompletion(operation); + } long now = lifecycleDatabaseTime(); if (operation.kind() == OperationKind.DELETE) { - if (session.workspace() != null && !List.of("CLOSED", "ARCHIVED").contains(operation.sessionStatusBefore())) { + if (session.workspace() != null && !List.of("CLOSED", "ARCHIVED").contains(operation.sessionStatusBefore()) + && operation.lifecycleProtocolVersion() != 1) { throw new IllegalStateException("Workspace deletion requires a closed Session"); } ToolPublicationRetentionStore.retire(jdbc, tenantId, sessionId, operationId); @@ -953,17 +1003,34 @@ private long lifecycleDatabaseTime() { @Transactional public boolean renewLifecycleOperation(String tenantId, String sessionId, String operationId, String owner, long generation, Duration duration) { + WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); long now = lifecycleDatabaseTime(); - return jdbc.update("UPDATE managed_agent_operation SET lease_until = ?, updated_at = ?" + int updated = jdbc.update("UPDATE managed_agent_operation SET lease_until = ?, updated_at = ?" + " WHERE tenant_id = ? AND session_id = ? AND operation_id = ? AND delivery_state = 'LEASED'" + " AND lease_owner = ? AND claim_generation = ? AND lease_until > ?", - Math.addExact(now, duration.toMillis()), now, tenantId, sessionId, operationId, owner, generation, now) == 1; + Math.addExact(now, duration.toMillis()), now, tenantId, sessionId, operationId, owner, generation, now); + if (updated == 1) { + syncLifecycleClaim(tenantId, sessionId, operationId); + } + return updated == 1; + } + + private void syncLifecycleClaim(String tenantId, String sessionId, String operationId) { + var rows = jdbc.queryForList("SELECT lifecycle_protocol_version, claim_generation, lease_until FROM managed_agent_operation" + + " WHERE tenant_id = ? AND session_id = ? AND operation_id = ?", tenantId, sessionId, operationId); + if (!rows.isEmpty() && ((Number) rows.getFirst().get("lifecycle_protocol_version")).intValue() == 1) { + var row = rows.getFirst(); + jdbc.update("UPDATE qwen_runtime_harness_drain SET claim_generation = ?, claim_lease_until = ?" + + " WHERE tenant_id = ? AND harness_session_id = ? AND operation_id = ? AND phase = 'LIFECYCLE_ONLY'", + row.get("claim_generation"), row.get("lease_until"), tenantId, sessionId, operationId); + } } @Override @Transactional public void blockLifecycleOperation(String tenantId, String sessionId, String operationId, String owner, long generation, String failureCode, long availableAt) { + WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); long now = lifecycleDatabaseTime(); jdbc.update("UPDATE managed_agent_operation SET state = 'RECOVERY_BLOCKED', delivery_state = 'BLOCKED'," + " error_code = ?, available_at = ?, lease_owner = NULL, lease_until = NULL, updated_at = ?," @@ -972,6 +1039,9 @@ public void blockLifecycleOperation(String tenantId, String sessionId, String op + " AND lease_owner = ? AND claim_generation = ? AND lease_until > ?", failureCode, Math.addExact(now, Math.max(0, availableAt - clock.millis())), now, tenantId, sessionId, operationId, owner, generation, now); + jdbc.update("UPDATE qwen_runtime_harness_drain SET claim_lease_until = NULL WHERE tenant_id = ?" + + " AND harness_session_id = ? AND operation_id = ? AND claim_generation = ?", + tenantId, sessionId, operationId, generation); } @Override @@ -979,6 +1049,7 @@ public void blockLifecycleOperation(String tenantId, String sessionId, String op public void retryOperation(String tenantId, String sessionId, String operationId, String owner, long claimGeneration, long availableAt) { + WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); long delay = Math.max(0, availableAt - clock.millis()); long now = lifecycleDatabaseTime(); availableAt = Math.addExact(now, delay); @@ -991,6 +1062,9 @@ public void retryOperation(String tenantId, String sessionId, + " AND lease_owner = ? AND claim_generation = ? AND lease_until > ?", availableAt, now, tenantId, sessionId, operationId, owner, claimGeneration, now); + jdbc.update("UPDATE qwen_runtime_harness_drain SET claim_lease_until = NULL WHERE tenant_id = ?" + + " AND harness_session_id = ? AND operation_id = ? AND claim_generation = ?", + tenantId, sessionId, operationId, claimGeneration); } public Admission replayCommand(String tenantId, String operation, diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedExtensionRecordStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedExtensionRecordStore.java index d179da039a3..1340c27ebd1 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedExtensionRecordStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedExtensionRecordStore.java @@ -266,6 +266,72 @@ ApplyResult apply(String tenantId, String workspaceId, String sessionId, return new ApplyResult(receipts, lastActivation); } + boolean hasNewLifecycleDispatch(String tenantId, String sessionId, byte[] bytes, + Function resources) { + boolean dispatch = false; + for (String line : new String(bytes, StandardCharsets.UTF_8).split("\n")) { + JsonNode event = parse(line).path("managedSession"); + JsonNode payload = event.path("payload"); + if ("input.accepted".equals(event.path("kind").asText()) + || "tool.intent".equals(event.path("kind").asText())) { + throw WorkspaceLifecycleStore.blocked("workspace_lifecycle_admission_closed"); + } + if ("model.attempt".equals(event.path("kind").asText()) + && "started".equals(payload.path("state").asText())) { + dispatch = true; + } + if (!"domain.committed".equals(event.path("kind").asText())) { + continue; + } + String domain = payload.path("domain").asText(); + if (!List.of("hook_execution", "hook_registration").contains(domain)) { + throw WorkspaceLifecycleStore.blocked("workspace_lifecycle_admission_closed"); + } + JsonNode next = readBody(resources.apply(payload.path("recordRef").path("resourceId").asText())); + if (!"dispatch_started".equals(next.path("run").path("execution").asText())) { + continue; + } + String field = "hook_execution".equals(domain) ? "hookExecutionId" : "registrationId"; + var previous = listRecords(tenantId, sessionId, domain).stream() + .filter(record -> next.path(field).equals(record.path(field))).findFirst(); + if (previous.isEmpty() || !"dispatch_started".equals(previous.get().path("run").path("execution").asText())) { + dispatch = true; + } + } + return dispatch; + } + + void requireLifecycleSettlement(String tenantId, String sessionId, byte[] bytes, + Function resources) { + for (String line : new String(bytes, StandardCharsets.UTF_8).split("\n")) { + JsonNode event = parse(line).path("managedSession"); + String kind = event.path("kind").asText(); + JsonNode payload = event.path("payload"); + if ("input.accepted".equals(kind) || "tool.intent".equals(kind) + || "model.attempt".equals(kind) && "started".equals(payload.path("state").asText())) { + throw WorkspaceLifecycleStore.blocked("workspace_lifecycle_admission_closed"); + } + if (!"domain.committed".equals(kind)) { + continue; + } + String domain = payload.path("domain").asText(); + if (!List.of("hook_execution", "hook_registration").contains(domain)) { + throw WorkspaceLifecycleStore.blocked("workspace_lifecycle_admission_closed"); + } + JsonNode next = readBody(resources.apply(payload.path("recordRef").path("resourceId").asText())); + String id = next.path(domain.equals("hook_execution") ? "hookExecutionId" + : "registrationId").asText(); + JsonNode previous = listRecords(tenantId, sessionId, domain).stream().filter(record -> + id.equals(record.path(domain.equals("hook_execution") ? "hookExecutionId" + : "registrationId").asText())) + .findFirst().orElse(null); + if (previous == null || "intent".equals(previous.path("run").path("execution").asText()) + && "dispatch_started".equals(next.path("run").path("execution").asText())) { + throw WorkspaceLifecycleStore.blocked("workspace_lifecycle_admission_closed"); + } + } + } + public TaskPage listTasks(String tenantId, String sessionId, Long beforeCreatedAt, String beforeTaskId, int limit) { List arguments = new ArrayList<>(); diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java index 77aec4dd60d..8ece63748fe 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java @@ -130,6 +130,76 @@ public ManagedSessionStore( new org.springframework.jdbc.datasource.DataSourceTransactionManager(jdbc.getDataSource())); } + private WorkspaceExecutionStore lifecycleExecution; + private AgentStateStore lifecycleSessions; + + @Autowired(required = false) + public void setLifecycleExecution(WorkspaceExecutionStore execution, AgentStateStore sessions) { + this.lifecycleExecution = execution; + this.lifecycleSessions = sessions; + } + + @Transactional + public void authorizeLifecycle(String tenantId, String sessionId, String writerToken, + ManagedSessionStoreModels.AuthorizeLifecycleRequest request, + com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { + WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); + ToolPublicationRetentionStore.lockTenant(jdbc, tenantId); + jdbc.queryForList("SELECT session_id FROM managed_agent_session WHERE tenant_id = ? AND session_id = ? FOR UPDATE", tenantId, sessionId); + WorkspaceLifecycleStore.requireClaim(jdbc, tenantId, sessionId, authority, true); + if (request.kind() != null) { + String kind = jdbc.queryForObject("SELECT operation_kind FROM managed_agent_operation WHERE tenant_id = ?" + + " AND session_id = ? AND operation_id = ?", String.class, tenantId, sessionId, authority.operationId()); + if (!kind.toLowerCase(java.util.Locale.ROOT).equals(request.kind())) { + throw WorkspaceLifecycleStore.blocked("workspace_lifecycle_operation_conflict"); + } + } else if (!"DRAINING".equals(jdbc.queryForObject("SELECT phase FROM qwen_runtime_harness_drain" + + " WHERE tenant_id = ? AND harness_session_id = ?", String.class, tenantId, sessionId))) { + throw WorkspaceLifecycleStore.blocked("workspace_lifecycle_hooks_unsettled"); + } + HeadRow head = requireHeadForUpdate(tenantId, sessionId); + requireHeadScope(head, tenantId, request.workspaceId(), sessionId); + requireWriter(head, request.writerId(), request.writerGeneration(), writerToken, databaseNow(), true); + } + + private void checkLifecycleWriter(String tenantId, String sessionId, + com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { + WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); + ToolPublicationRetentionStore.lockTenant(jdbc, tenantId); + if (authority != null) { + WorkspaceLifecycleStore.requireClaim(jdbc, tenantId, sessionId, authority, true); + } + } + + @Transactional + public void authorizeOrdinary(String tenantId, String sessionId, String writerToken, + ManagedSessionStoreModels.AuthorizeLifecycleRequest request) { + WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); + ToolPublicationRetentionStore.lockTenant(jdbc, tenantId); + if (!"legacy-close".equals(request.kind()) || !WorkspaceLifecycleStore.legacyClose(jdbc, tenantId, sessionId)) { + requireOrdinaryAdmission(tenantId, sessionId); + } + HeadRow head = requireHeadForUpdate(tenantId, sessionId); + requireHeadScope(head, tenantId, request.workspaceId(), sessionId); + requireWriter(head, request.writerId(), request.writerGeneration(), writerToken, databaseNow(), true); + } + + private void requireOrdinaryAdmission(String tenantId, String sessionId) { + if (lifecycleFenced(tenantId, sessionId)) { + throw conflict("managed_session_lifecycle_active", "Ordinary execution admission is closed."); + } + } + + private boolean lifecycleFenced(String tenantId, String sessionId) { + return !jdbc.queryForList("SELECT phase FROM qwen_runtime_harness_drain WHERE tenant_id = ?" + + " AND harness_session_id = ? FOR UPDATE", tenantId, sessionId).isEmpty(); + } + + private boolean lifecycleProtocolFenced(String tenantId, String sessionId) { + return !jdbc.queryForList("SELECT operation_id FROM qwen_runtime_harness_drain WHERE tenant_id = ?" + + " AND harness_session_id = ? AND operation_id IS NOT NULL FOR UPDATE", tenantId, sessionId).isEmpty(); + } + @Autowired(required = false) public void setPublicationObjects(ToolPublicationObjectStore publicationObjects) { this.publicationObjects = publicationObjects; @@ -178,6 +248,18 @@ PublicationWriter lockPublicationWriter(String tenant, String workspace, @Transactional public WriterGrant acquireWriter(String tenantId, String sessionId, String writerToken, AcquireWriterRequest request) { + return acquireWriter(tenantId, sessionId, writerToken, request, null); + } + + @Transactional + public WriterGrant acquireWriter(String tenantId, String sessionId, String writerToken, + AcquireWriterRequest request, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { + checkLifecycleWriter(tenantId, sessionId, authority); + if (authority != null) { + WorkspaceLifecycleStore.requireClaim(jdbc, tenantId, sessionId, authority, false); + } else { + requireOrdinaryAdmission(tenantId, sessionId); + } validateScope(tenantId, request.workspaceId(), sessionId); requireCredential(tenantId, request.workspaceId(), sessionId, writerToken); @@ -188,7 +270,7 @@ public WriterGrant acquireWriter(String tenantId, String sessionId, List closed = jdbc.query("SELECT status FROM managed_agent_session" + " WHERE tenant_id = ? AND session_id = ? AND workspace_id IS NOT NULL FOR UPDATE", (row, index) -> row.getString("status"), tenantId, sessionId); - if (!closed.isEmpty() && !"ACTIVE".equals(closed.getFirst())) { + if (authority == null && !closed.isEmpty() && !"ACTIVE".equals(closed.getFirst())) { throw conflict("managed_session_not_writable", "The Session is closing or closed."); } String tokenHash = tokenHash(writerToken); @@ -272,6 +354,13 @@ public WriterGrant acquireWriter(String tenantId, String sessionId, @Transactional public WriterGrant renewWriter(String tenantId, String sessionId, String writerToken, RenewWriterRequest request) { + return renewWriter(tenantId, sessionId, writerToken, request, null); + } + + @Transactional + public WriterGrant renewWriter(String tenantId, String sessionId, String writerToken, + RenewWriterRequest request, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { + checkLifecycleWriter(tenantId, sessionId, authority); validateScope(tenantId, request.workspaceId(), sessionId); requireCredential(tenantId, request.workspaceId(), sessionId, writerToken); @@ -298,6 +387,13 @@ public WriterGrant renewWriter(String tenantId, String sessionId, @Transactional public SealReceipt sealWriter(String tenantId, String sessionId, String writerToken, SealWriterRequest request) { + return sealWriter(tenantId, sessionId, writerToken, request, null); + } + + @Transactional + public SealReceipt sealWriter(String tenantId, String sessionId, String writerToken, + SealWriterRequest request, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { + checkLifecycleWriter(tenantId, sessionId, authority); validateScope(tenantId, request.workspaceId(), sessionId); requireCredential(tenantId, request.workspaceId(), sessionId, writerToken); @@ -368,6 +464,13 @@ public RecoveryStateReceipt blockRecovery(String tenantId, @Transactional public CommitReceipt commit(String tenantId, String sessionId, String writerToken, CommitTransactionRequest request) { + return commit(tenantId, sessionId, writerToken, request, null); + } + + @Transactional + public CommitReceipt commit(String tenantId, String sessionId, String writerToken, + CommitTransactionRequest request, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { + checkLifecycleWriter(tenantId, sessionId, authority); validateScope(tenantId, request.workspaceId(), sessionId); requireCredential(tenantId, request.workspaceId(), sessionId, writerToken); @@ -392,6 +495,24 @@ sessionId, commandKeyHash(request.operation(), String scopeKey = sessionScopeKey(tenantId, sessionId); commitResources(scopeKey, tenantId, sessionId, request, revision, now, validated.resources()); + if (authority != null && extensionRecords.hasNewLifecycleDispatch(tenantId, sessionId, validated.recordBytes(), + resourceId -> storedResource(scopeKey, tenantId, request.workspaceId(), sessionId, resourceId))) { + if (lifecycleExecution == null || lifecycleSessions == null) { + throw WorkspaceLifecycleStore.blocked("workspace_lifecycle_protocol_unavailable"); + } + try { + lifecycleExecution.authorizeLifecycle(lifecycleSessions.requireSession(tenantId, sessionId), authority); + } catch (com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException refusal) { + if (refusal.getStatusCode() == 409 && "workspace_unavailable".equals(refusal.getCode())) { + throw WorkspaceLifecycleStore.blocked("workspace_lifecycle_authorization_revoked"); + } + throw refusal; + } + } + if (authority == null && lifecycleProtocolFenced(tenantId, sessionId)) { + extensionRecords.requireLifecycleSettlement(tenantId, sessionId, validated.recordBytes(), + resourceId -> storedResource(scopeKey, tenantId, request.workspaceId(), sessionId, resourceId)); + } var applied = extensionRecords.apply(tenantId, request.workspaceId(), sessionId, request.firstSequence(), request.eventCount(), validated.recordBytes(), resourceId -> storedResource( diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStoreModels.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStoreModels.java index 4ca4f7d224d..19d27366059 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStoreModels.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStoreModels.java @@ -56,6 +56,16 @@ public final class ManagedSessionStoreModels { private ManagedSessionStoreModels() { } + public record AuthorizeLifecycleRequest( + @NotBlank @Size(max = 512) String workspaceId, + @NotBlank @Size(max = 512) String writerId, + @Min(1) @Max(MAX_SAFE_COUNTER) long writerGeneration, + String kind) { + public AuthorizeLifecycleRequest(String workspaceId, String writerId, long writerGeneration) { + this(workspaceId, writerId, writerGeneration, null); + } + } + public record AcquireWriterRequest( @NotBlank @Size(max = 512) String workspaceId, @NotBlank @Size(max = 512) String writerId, diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/StoreModels.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/StoreModels.java index 13d736f7b30..a3fbd32e8f1 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/StoreModels.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/StoreModels.java @@ -101,7 +101,13 @@ public record OperationRecord(String tenantId, String sessionId, String operationId, OperationKind kind, String requestDigest, String state, String admissionStage, String deliveryState, String sessionStatusBefore, String receiptId, String leaseOwner, - long claimGeneration, int attemptCount, String failureCode) { + long claimGeneration, int attemptCount, String failureCode, int lifecycleProtocolVersion) { + public OperationRecord(String tenantId, String sessionId, String operationId, OperationKind kind, + String requestDigest, String state, String admissionStage, String deliveryState, String sessionStatusBefore, + String receiptId, String leaseOwner, long claimGeneration, int attemptCount, String failureCode) { + this(tenantId, sessionId, operationId, kind, requestDigest, state, admissionStage, deliveryState, + sessionStatusBefore, receiptId, leaseOwner, claimGeneration, attemptCount, failureCode, 0); + } public OperationRecord(String tenantId, String sessionId, String operationId, OperationKind kind, String requestDigest, String state, String admissionStage, String deliveryState, String sessionStatusBefore, String receiptId, String leaseOwner, long claimGeneration, int attemptCount) { diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceExecutionStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceExecutionStore.java index 0efe7f43d78..9e664d37939 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceExecutionStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceExecutionStore.java @@ -50,9 +50,38 @@ public void authorize(SessionRecord session) { } } + public void authorizeLifecycle(SessionRecord session, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { + WorkspaceLifecycleStore.requireClaim(jdbc, session.tenantId(), session.sessionId(), authority, false); + authorizePassiveAttachment(session, authority); + if (storageGuard != null) { + storageGuard.verify(session.workspace()); + } + } + + public void authorizeLegacyClose(SessionRecord session) { + if (!WorkspaceLifecycleStore.legacyClose(jdbc, session.tenantId(), session.sessionId())) { + throw unavailable(); + } + authorizePassiveAttachment(session, null, true); + if (storageGuard != null) { + storageGuard.verify(session.workspace()); + } + } + public void authorizePassiveAttachment(SessionRecord session) { + authorizePassiveAttachment(session, null); + } + + private void authorizePassiveAttachment(SessionRecord session, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { + authorizePassiveAttachment(session, authority, false); + } + + private void authorizePassiveAttachment(SessionRecord session, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority, + boolean legacyClose) { ContextBinding binding = session.workspace(); - if (binding == null || !"ACTIVE".equals(session.status()) + String expectedStatus = authority == null && !legacyClose ? "ACTIVE" : session.status(); + if (binding == null || !(legacyClose ? "CLOSING".equals(session.status()) : authority == null ? "ACTIVE".equals(session.status()) + : java.util.List.of("CLOSING", "DELETING").contains(session.status())) || session.deletedAt() != null || !"qwen-code".equals(session.agentId()) || !session.tenantId().equals(binding.getTenantId()) || !WorkspaceExecutionProfile.CONTEXT_CONFIG_REF.equals( @@ -84,7 +113,7 @@ public void authorizePassiveAttachment(SessionRecord session) { (row, index) -> session.tenantId().equals(row.getString("tenant_id")) && session.sessionId().equals(row.getString("session_id")) && "qwen-code".equals(row.getString("session_agent")) - && "ACTIVE".equals(row.getString("session_status")) + && expectedStatus.equals(row.getString("session_status")) && row.getObject("session_deleted_at") == null && binding.getWorkspaceId().equals(row.getString("session_workspace")) && binding.getWorkspaceGeneration() == row.getLong("session_generation") diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStore.java new file mode 100644 index 00000000000..e39db7656a6 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStore.java @@ -0,0 +1,360 @@ +package com.alibaba.qwen.code.managedagent.store; + +import com.alibaba.qwen.code.managedagent.api.ApiException; +import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationRecord; +import com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository; +import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRepository; +import com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.jdbc.core.ConnectionCallback; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +public class WorkspaceLifecycleStore { + public static boolean legacyClose(JdbcTemplate jdbc, String tenant, String session) { + long now = jdbc.queryForObject("SELECT CURRENT_TIMESTAMP(6)", java.sql.Timestamp.class).getTime(); + return !jdbc.queryForList("SELECT o.operation_id FROM managed_agent_operation o" + + " JOIN managed_agent_session s ON s.tenant_id = o.tenant_id AND s.session_id = o.session_id" + + " JOIN qwen_runtime_harness_drain f ON f.tenant_id = o.tenant_id AND f.harness_session_id = o.session_id" + + " WHERE o.tenant_id = ? AND o.session_id = ? AND o.operation_kind = 'CLOSE'" + + " AND o.lifecycle_protocol_version = 0 AND o.delivery_state = 'LEASED'" + + " AND o.lease_until > ? AND s.status = 'CLOSING'" + + " AND f.phase = 'DRAINING' AND f.operation_id IS NULL FOR UPDATE", tenant, session, now).isEmpty(); + } + private final JdbcTemplate jdbc; + private final ObjectMapper json; + private final ManagedExtensionRecordStore records; + private final ObjectProvider bindings; + + public WorkspaceLifecycleStore(JdbcTemplate jdbc, ObjectMapper json, ObjectProvider bindings) { + this.jdbc = jdbc; + this.json = json; + this.records = new ManagedExtensionRecordStore(jdbc); + this.bindings = bindings; + } + + public static void lockPlacement(JdbcTemplate jdbc, String tenant) { + jdbc.execute((ConnectionCallback) connection -> { + JdbcRuntimeBindingRepository.lockPlacementDomain(connection, tenant); + return null; + }); + } + + public static void requireClaim(JdbcTemplate jdbc, String tenant, String session, + RuntimeLifecycleAuthority authority, boolean drainingAllowed) { + if (authority == null) { + throw blocked("workspace_lifecycle_claim_fenced"); + } + var rows = jdbc.queryForList("SELECT o.claim_generation, o.lease_until, o.delivery_state, o.lifecycle_protocol_version," + + " s.status, f.phase, f.operation_id FROM managed_agent_operation o" + + " JOIN managed_agent_session s ON s.tenant_id = o.tenant_id AND s.session_id = o.session_id" + + " JOIN qwen_runtime_harness_drain f ON f.tenant_id = s.tenant_id AND f.harness_session_id = s.session_id" + + " WHERE o.tenant_id = ? AND o.session_id = ? AND o.operation_id = ? FOR UPDATE", tenant, session, authority.operationId()); + long now = jdbc.queryForObject("SELECT UNIX_TIMESTAMP(), EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(6))", + (row, index) -> row.getLong(1) * 1000 + row.getLong(2) / 1000); + if (rows.size() != 1) { + throw blocked("workspace_lifecycle_claim_fenced"); + } + var row = rows.getFirst(); + if (((Number) row.get("claim_generation")).longValue() != authority.claimGeneration() + || !(row.get("lease_until") instanceof Number until) || until.longValue() <= now + || !"LEASED".equals(row.get("delivery_state")) + || ((Number) row.get("lifecycle_protocol_version")).intValue() != 1 + || !List.of("CLOSING", "DELETING").contains(row.get("status")) + || !authority.operationId().equals(row.get("operation_id")) + || !"LIFECYCLE_ONLY".equals(row.get("phase")) && !(drainingAllowed && "DRAINING".equals(row.get("phase")))) { + throw blocked("workspace_lifecycle_claim_fenced"); + } + } + + public static void requireIdleJournal(JdbcTemplate jdbc, ObjectMapper json, String tenant, String session) { + var heads = jdbc.queryForList("SELECT compacted_through_revision FROM qwen_managed_session_journal_head" + + " WHERE tenant_id = ? AND session_id = ? FOR UPDATE", tenant, session); + if (heads.isEmpty()) { + return; + } + if (((Number) heads.getFirst().get("compacted_through_revision")).longValue() != 0) { + throw blocked("workspace_lifecycle_journal_unverified"); + } + var pending = new java.util.HashSet(); + jdbc.query("SELECT record_bytes FROM qwen_managed_session_journal_tx WHERE tenant_id = ? AND session_id = ?" + + " ORDER BY journal_revision FOR UPDATE", row -> { + for (String line : new String(row.getBytes(1), StandardCharsets.UTF_8).split("\n")) { + try { + JsonNode event = json.readTree(line).path("managedSession"); + String turn = event.path("payload").path("turnId").asText(); + if ("input.accepted".equals(event.path("kind").asText())) { + pending.add(turn); + } else if ("turn.settled".equals(event.path("kind").asText())) { + pending.remove(turn); + } + } catch (java.io.IOException error) { + throw blocked("workspace_lifecycle_journal_unverified"); + } + } + }, tenant, session); + if (!pending.isEmpty()) { + throw new ApiException(HttpStatus.CONFLICT, "turn_active", "The Session has an active Turn."); + } + } + + @Transactional + public JsonNode recoverEffects(OperationRecord operation) { + lock(operation); + requireClaim(jdbc, operation.tenantId(), operation.sessionId(), authority(operation), true); + String saved = jdbc.queryForObject("SELECT lifecycle_effects_receipt_json FROM managed_agent_operation" + + " WHERE tenant_id = ? AND session_id = ? AND operation_id = ? FOR UPDATE", String.class, + operation.tenantId(), operation.sessionId(), operation.operationId()); + if (saved != null) { + return parse(saved); + } + JsonNode header = header(operation.tenantId(), operation.sessionId()); + var receipt = envelope(operation); + var effects = receipt.putArray("effects"); + if (header == null) { + if (!neverInitialized(operation)) { + return null; + } + receipt.put("neverInitialized", true); + } else { + JsonNode definitionRef = header.path("definitionRef"); + JsonNode definition = records.readRecordResource(operation.tenantId(), operation.sessionId(), definitionRef); + receipt.set("definitionRef", definitionRef); + if (definition.has("hookCatalog")) { + var executions = records.listRecords(operation.tenantId(), operation.sessionId(), "hook_execution"); + for (String event : events(operation)) { + String id = occurrence(event, operation.operationId()); + var refs = jdbc.queryForList("SELECT record_resource_id FROM qwen_managed_session_extension_record" + + " WHERE tenant_id = ? AND session_id = ? AND domain = 'hook_execution' ORDER BY first_sequence", + String.class, operation.tenantId(), operation.sessionId()); + JsonNode marker = executions.stream().filter(record -> id.equals(record.path("hookExecutionId").asText())) + .findFirst().orElse(null); + if (marker == null || marker.path("resultRef").isNull() || !marker.has("resultRef")) { + return null; + } + JsonNode ref = null; + for (String resourceId : refs) { + JsonNode candidate = resourceRef(operation, resourceId); + if (id.equals(records.readRecordResource(operation.tenantId(), operation.sessionId(), candidate) + .path("hookExecutionId").asText())) { + ref = candidate; + break; + } + } + if (ref == null) { + return null; + } + effects.addObject().put("event", event).set("recordRef", ref); + } + } + } + saveLocked(operation, receipt); + return receipt; + } + + @Transactional + public void saveEffects(OperationRecord operation, JsonNode receipt) { + lock(operation); + requireClaim(jdbc, operation.tenantId(), operation.sessionId(), authority(operation), false); + saveLocked(operation, receipt); + } + + private void saveLocked(OperationRecord operation, JsonNode receipt) { + verifyEffects(operation, receipt); + jdbc.update("UPDATE managed_agent_operation SET lifecycle_effects_receipt_json = ?" + + " WHERE tenant_id = ? AND session_id = ? AND operation_id = ? AND claim_generation = ?", + receipt.toString(), operation.tenantId(), operation.sessionId(), operation.operationId(), operation.claimGeneration()); + jdbc.update("UPDATE qwen_runtime_harness_drain SET phase = 'DRAINING', claim_lease_until = NULL" + + " WHERE tenant_id = ? AND harness_session_id = ? AND operation_id = ?", + operation.tenantId(), operation.sessionId(), operation.operationId()); + } + + public void verifyCompletion(OperationRecord operation) { + String saved = jdbc.queryForObject("SELECT lifecycle_effects_receipt_json FROM managed_agent_operation" + + " WHERE tenant_id = ? AND session_id = ? AND operation_id = ? FOR UPDATE", String.class, + operation.tenantId(), operation.sessionId(), operation.operationId()); + if (saved == null) { + throw blocked("workspace_lifecycle_hooks_unsettled"); + } + verifyEffects(operation, parse(saved)); + var writers = jdbc.queryForList("SELECT state, CASE WHEN writer_lease_until > CURRENT_TIMESTAMP(6) THEN 1 ELSE 0 END AS live_writer" + + " FROM qwen_managed_session_journal_head WHERE tenant_id = ? AND session_id = ? FOR UPDATE", + operation.tenantId(), operation.sessionId()); + if (!writers.isEmpty() && "ACTIVE".equals(writers.getFirst().get("state")) + && ((Number) writers.getFirst().get("live_writer")).intValue() != 0) { + throw blocked("workspace_lifecycle_writer_active"); + } + RuntimeBindingRepository repository = bindings.getIfAvailable(); + if (repository == null) { + throw blocked("workspace_close_identity_unverified"); + } + jdbc.execute((ConnectionCallback) connection -> { + repository.verifyHarnessStopped(connection, operation.tenantId(), operation.sessionId()); + return null; + }); + } + + private void lock(OperationRecord operation) { + lockPlacement(jdbc, operation.tenantId()); + ToolPublicationRetentionStore.lockTenant(jdbc, operation.tenantId()); + jdbc.queryForList("SELECT session_id FROM managed_agent_session WHERE tenant_id = ? AND session_id = ? FOR UPDATE", + operation.tenantId(), operation.sessionId()); + } + + private void verifyEffects(OperationRecord operation, JsonNode receipt) { + if (receipt.path("protocolVersion").asInt() != 1 || !receipt.path("sessionKey").equals(sessionKey(operation)) + || !operation.operationId().equals(receipt.path("operationId").asText()) + || !operation.kind().name().toLowerCase(java.util.Locale.ROOT).equals(receipt.path("kind").asText()) + || !receipt.path("effects").isArray()) { + throw blocked("workspace_lifecycle_receipt_invalid"); + } + JsonNode header = header(operation.tenantId(), operation.sessionId()); + if (receipt.path("neverInitialized").asBoolean()) { + if (header != null || !receipt.path("effects").isEmpty() || !neverInitialized(operation)) { + throw blocked("workspace_lifecycle_receipt_invalid"); + } + return; + } + if (header == null || !header.path("definitionRef").equals(receipt.path("definitionRef"))) { + throw blocked("workspace_lifecycle_receipt_invalid"); + } + JsonNode definition = records.readRecordResource(operation.tenantId(), operation.sessionId(), receipt.path("definitionRef")); + if (!"hosted-workspace-files/1".equals(definition.path("toolProfile").asText())) { + throw blocked("workspace_lifecycle_profile_unavailable"); + } + for (JsonNode execution : records.listRecords(operation.tenantId(), operation.sessionId(), "hook_execution")) { + if (execution.path("resultRef").isNull() + && !"not_started_proven".equals(execution.path("run").path("execution").asText()) + || "recovery_blocked".equals(execution.path("run").path("state").asText())) { + throw blocked("workspace_lifecycle_hooks_unsettled"); + } + } + if (!definition.has("hookCatalog")) { + if (!receipt.path("effects").isEmpty()) { + throw blocked("workspace_lifecycle_receipt_invalid"); + } + return; + } + List events = events(operation); + if (receipt.path("effects").size() != events.size()) { + throw blocked("workspace_lifecycle_hooks_unsettled"); + } + for (int index = 0; index < events.size(); index++) { + String event = events.get(index); + JsonNode effect = receipt.path("effects").get(index); + String latest = jdbc.queryForList("SELECT record_resource_id FROM qwen_managed_session_extension_record WHERE" + + " tenant_id = ? AND session_id = ? AND domain = 'hook_execution' AND record_id = ? FOR UPDATE", + String.class, operation.tenantId(), operation.sessionId(), occurrence(event, operation.operationId())) + .stream().findFirst().orElse(null); + if (latest == null || !latest.equals(effect.path("recordRef").path("resourceId").asText())) { + throw blocked("workspace_lifecycle_receipt_invalid"); + } + JsonNode record = records.readRecordResource(operation.tenantId(), operation.sessionId(), effect.path("recordRef")); + ManagedHookRecords.requireExecution(record); + if (!event.equals(effect.path("event").asText()) || !event.equals(record.path("eventName").asText()) + || !occurrence(event, operation.operationId()).equals(record.path("hookExecutionId").asText()) + || !"__plan__".equals(record.path("hookId").asText()) || record.path("resultRef").isNull() + || "recovery_blocked".equals(record.path("run").path("state").asText())) { + throw blocked("workspace_lifecycle_hooks_unsettled"); + } + records.readRecordResource(operation.tenantId(), operation.sessionId(), record.path("resultRef")); + JsonNode plan = records.readRecordResource(operation.tenantId(), operation.sessionId(), record.path("planRef")); + if (!event.equals(plan.path("input").path("hook_event_name").asText()) + || !operation.sessionId().equals(plan.path("input").path("session_id").asText())) { + throw blocked("workspace_lifecycle_receipt_invalid"); + } + } + } + + private boolean neverInitialized(OperationRecord operation) { + var heads = jdbc.queryForList("SELECT journal_revision, CASE WHEN state = 'ACTIVE' AND" + + " writer_lease_until > CURRENT_TIMESTAMP(6) THEN 1 ELSE 0 END AS live_writer" + + " FROM qwen_managed_session_journal_head WHERE tenant_id = ? AND session_id = ? FOR UPDATE", + operation.tenantId(), operation.sessionId()); + if (!heads.isEmpty() && (((Number) heads.getFirst().get("journal_revision")).longValue() != 0 + || ((Number) heads.getFirst().get("live_writer")).intValue() != 0)) { + return false; + } + return jdbc.queryForObject("SELECT harness_boot_id FROM managed_agent_session WHERE tenant_id = ? AND session_id = ?", + String.class, operation.tenantId(), operation.sessionId()) == null + && jdbc.queryForObject("SELECT COUNT(*) FROM qwen_managed_session_journal_tx WHERE tenant_id = ? AND session_id = ?", + Integer.class, operation.tenantId(), operation.sessionId()) == 0 + && records.listRecords(operation.tenantId(), operation.sessionId(), "hook_execution").isEmpty() + && records.listRecords(operation.tenantId(), operation.sessionId(), "hook_registration").isEmpty(); + } + + private JsonNode resourceRef(OperationRecord operation, String resource) { + return jdbc.query("SELECT * FROM qwen_managed_session_resource WHERE tenant_id = ? AND session_id = ? AND resource_id = ?", + (row, index) -> json.createObjectNode().put("resourceId", resource).put("kind", row.getString("kind")) + .put("schemaVersion", row.getInt("schema_version")).put("byteLength", row.getLong("byte_length")) + .put("digest", row.getString("sha256")), operation.tenantId(), operation.sessionId(), resource).stream() + .findFirst().orElseThrow(() -> blocked("workspace_lifecycle_receipt_invalid")); + } + + private JsonNode header(String tenant, String session) { + var rows = jdbc.queryForList("SELECT record_bytes FROM qwen_managed_session_journal_tx WHERE tenant_id = ? AND session_id = ?" + + " ORDER BY journal_revision LIMIT 1", byte[].class, tenant, session); + if (rows.isEmpty()) { + return null; + } + for (String line : new String(rows.getFirst(), StandardCharsets.UTF_8).split("\\n")) { + JsonNode record = parse(line); + if ("managed_session_header_v1".equals(record.path("subtype").asText())) { + return record.path("managedSession"); + } + } + throw blocked("workspace_lifecycle_receipt_invalid"); + } + + private com.fasterxml.jackson.databind.node.ObjectNode envelope(OperationRecord operation) { + var node = json.createObjectNode().put("protocolVersion", 1).put("operationId", operation.operationId()) + .put("kind", operation.kind().name().toLowerCase(java.util.Locale.ROOT)); + node.set("sessionKey", sessionKey(operation)); + return node; + } + + private JsonNode sessionKey(OperationRecord operation) { + String workspace = jdbc.queryForObject("SELECT workspace_id FROM managed_agent_session WHERE tenant_id = ? AND session_id = ?", + String.class, operation.tenantId(), operation.sessionId()); + return json.createObjectNode().put("tenantId", operation.tenantId()).put("workspaceId", workspace).put("sessionId", operation.sessionId()); + } + + private List events(OperationRecord operation) { + return operation.kind() == StoreModels.OperationKind.CLOSE ? List.of("SessionEnd") : List.of("SessionEnd", "SessionDelete"); + } + + private String occurrence(String event, String operationId) { + try { + return "hook-plan-" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest( + json.writeValueAsBytes(List.of(event, operationId)))); + } catch (java.io.IOException | NoSuchAlgorithmException error) { + throw new IllegalStateException(error); + } + } + + private JsonNode parse(String value) { + try { + return json.readTree(value); + } catch (java.io.IOException error) { + throw blocked("workspace_lifecycle_receipt_invalid"); + } + } + + public static RuntimeLifecycleAuthority authority(OperationRecord operation) { + return new RuntimeLifecycleAuthority(operation.operationId(), operation.claimGeneration()); + } + + public static ApiException blocked(String code) { + return new ApiException(HttpStatus.CONFLICT, code, "Workspace lifecycle recovery is blocked."); + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V35__workspace_session_lifecycle.sql b/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V35__workspace_session_lifecycle.sql new file mode 100644 index 00000000000..c3432583d8b --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V35__workspace_session_lifecycle.sql @@ -0,0 +1,6 @@ +ALTER TABLE managed_agent_operation ADD COLUMN lifecycle_protocol_version INT NOT NULL DEFAULT 0; +ALTER TABLE managed_agent_operation ADD COLUMN lifecycle_effects_receipt_json LONGTEXT; +ALTER TABLE qwen_runtime_harness_drain ADD COLUMN phase VARCHAR(32) NOT NULL DEFAULT 'DRAINING'; +ALTER TABLE qwen_runtime_harness_drain ADD COLUMN operation_id VARCHAR(128); +ALTER TABLE qwen_runtime_harness_drain ADD COLUMN claim_generation BIGINT NOT NULL DEFAULT 0; +ALTER TABLE qwen_runtime_harness_drain ADD COLUMN claim_lease_until BIGINT; diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json index d629b090020..f5a2688f8c8 100644 --- a/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json +++ b/packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json @@ -204,7 +204,7 @@ "tags": ["Public Sessions"], "operationId": "createSession", "x-qwen-implementation-status": "implemented", - "description": "Workspace selection is persisted with the original actor-scoped creation receipt. With the deployment's G0 file admission opt-in, qwen-code Sessions in an authorized, preregistered Workspace may include initial input under the fixed preapproved file-tool profile. Public callers cannot select the profile. Otherwise Workspace creation must have empty input. The Session creator may submit later Turns and cancel its running Turns under the same opt-in, and may rename the Session, while the creator currently holds Workspace read and create grants on a registry row whose state is ACTIVE and the Session is an active, undeleted qwen-code Session on the frozen execution profile; Workspace close follows its separate close capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close; cwd operations remain gated. This surface has no per-caller capability flag for later Turns, so callers must handle 409 workspace_unavailable. Check replay before resolving defaults; a durable binding is not proof of Runtime readiness.", + "description": "Workspace selection is persisted with the original actor-scoped creation receipt. With the deployment's G0 file admission opt-in, qwen-code Sessions in an authorized, preregistered Workspace may include initial input under the fixed preapproved file-tool profile. Public callers cannot select the profile. Otherwise Workspace creation must have empty input. The Session creator may submit later Turns and 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 and unarchive follow retention capabilities after reliable Workspace close; delete supports either L3 idle ACTIVE files Sessions or L2 CLOSED/ARCHIVED Sessions; 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" @@ -398,7 +398,7 @@ "tags": ["Public Sessions"], "operationId": "deleteSession", "x-qwen-implementation-status": "implemented", - "description": "Unbound Sessions accept active, closed or archived state. Returns 409 turn_active while a Turn is active and session_operation_active while another lifecycle change is pending. The same key from the same actor returns the original operation, also after the Session was deleted. Workspace-bound delete accepts only closed or archived Sessions with completed reliable-close evidence and the creator with current read access. Active deletion returns 409 session_state_conflict. Completion atomically retires private recovery/publication access with the tombstone, operation receipt and terminal event; it calls neither Harness nor Runtime and erases no shared Workspace files or backup bytes. Operations remain readable under current read grants; the same authorized actor/key can replay after deletion. A readable non-creator returns 403; missing close evidence returns 409 workspace_unavailable.", + "description": "Workspace-bound ACTIVE deletion supports idle hosted-workspace-files/1 Sessions when L3 lifecycle protocol and reliable Runtime stop are available. Only the creator with current read access may admit it; accepted, running, cancelling or approval-waiting Turns return 409 turn_active. Admission persists a lifecycle-only execution fence and a versioned actor-scoped operation. SessionEnd settles before SessionDelete; committed Hook results are verified in Session Store and saved before permanent draining, attachment cleanup and original worker stop. Unknown outcomes or missing original stop evidence remain recovery_blocked and are never replayed on a replacement worker. CLOSED/ARCHIVED deletion retains L2: completed reliable-close evidence is required and no Harness or Hook is invoked. Completion atomically commits retirement, tombstone, receipt and terminal event, retaining shared Workspace files and neighboring holders. Authorized same-actor/key replays return the original operation, including after deletion; readable non-creators return 403. Missing lifecycle capability or close evidence returns 409 workspace_unavailable.", "parameters": [ { "$ref": "#/components/parameters/IdempotencyKey" @@ -406,7 +406,7 @@ ], "responses": { "202": { - "description": "Deletion accepted. The Session stops accepting input and reads deleting. If it was active, the operation first closes it as a close does; it then drains the Session's Runtime binding and leaves a tombstone: Session reads return 404, while its operations stay readable. The shared Workspace is never deleted, and no Session content is erased yet. 202 does not mean that tools have stopped.", + "description": "Deletion accepted. The Session stops accepting input and reads deleting. For a supported ACTIVE Workspace files Session, SessionEnd and then SessionDelete settle under L3 before permanent draining and verified original Runtime stop; CLOSED/ARCHIVED Workspace Sessions use independent L2 retirement. Unbound Sessions retain their existing lifecycle flow. Completion leaves a tombstone: Session reads return 404, while its operations stay readable. The shared Workspace is never deleted, and no Session content is erased yet. 202 does not mean that tools have stopped.", "content": { "application/json": { "schema": { @@ -1209,7 +1209,7 @@ "tags": ["WebShell"], "operationId": "webShellCreateSession", "x-qwen-implementation-status": "implemented", - "description": "Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns and cancel its running Turns under the same opt-in, and may rename the Session, 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.", + "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 and unarchive follow retention capabilities after reliable Workspace close; delete supports either L3 idle ACTIVE files Sessions or L2 CLOSED/ARCHIVED Sessions; 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": { @@ -1875,7 +1875,7 @@ "tags": ["Public Sessions"], "operationId": "closeSession", "x-qwen-implementation-status": "implemented", - "description": "Accepts an active Session; 409 session_state_conflict otherwise. Supported hosted-workspace-files/1 Sessions may close when session_close is true. Only the creator with current read access may close a bound Session: unreadable returns 404, readable non-creator returns 403. Active, cancelling or approval-waiting Turns return 409 turn_active; another pending lifecycle operation returns session_operation_active. Unsupported Workspace close returns workspace_unavailable. The same actor and key replay the original operation.", + "description": "Accepts an active Session; 409 session_state_conflict otherwise. Supported hosted-workspace-files/1 Sessions may close when session_close is true. Only the creator with current read access may close a bound Session: unreadable returns 404, readable non-creator returns 403. Active, cancelling or approval-waiting Turns return 409 turn_active; another pending lifecycle operation returns session_operation_active. Unsupported Workspace close returns workspace_unavailable. The same actor and key replay the original operation. New L3 Workspace close operations run SessionEnd only, retain data, and finish after durable effects verification and original Runtime stop. SessionDelete is reserved for ACTIVE delete. Missing protocol support rejects new admission; previously admitted operations retain their saved protocol. Unknown outcomes remain recovery_blocked.", "responses": { "202": { "description": "Close durably accepted. The Session stops accepting input and reads closing. A bound Session completes only after its permanent Runtime admission fence is stored, no valid journal writer or unsettled execution remains, all original Runtime Sessions and holders are released, and original worker stop proof is persisted; the Session then reads closed. Unknown original identities or executions report recovery_blocked with a stable failure_code and keep the Session closing. History, Artifacts and Workspace files are retained. 202 does not mean that tools have stopped.", @@ -2199,7 +2199,7 @@ } } }, - "description": "Uses the same close admission, creator authorization and durable cleanup semantics as the public close route. sessionClose describes deployment support; it does not grant actor authorization." + "description": "Uses the same close admission, creator authorization and durable cleanup semantics as the public close route. sessionClose describes deployment support; it does not grant actor authorization. New L3 Workspace close operations run SessionEnd only, retain data, and finish after durable effects verification and original Runtime stop. SessionDelete is reserved for ACTIVE delete. Missing protocol support rejects new admission; previously admitted operations retain their saved protocol. Unknown outcomes remain recovery_blocked." } }, "/api/agent/web-shell/v1/sessions/archive": { @@ -2295,7 +2295,7 @@ } } }, - "description": "Workspace-bound delete accepts only closed or archived Sessions with completed reliable-close evidence and the creator with current read access. Active deletion returns 409 session_state_conflict. Completion atomically retires private recovery/publication access with the tombstone, operation receipt and terminal event; it calls neither Harness nor Runtime and erases no shared Workspace files or backup bytes. Operations remain readable under current read grants; the same authorized actor/key can replay after deletion. A readable non-creator returns 403; missing close evidence returns 409 workspace_unavailable." + "description": "Workspace-bound ACTIVE deletion supports idle hosted-workspace-files/1 Sessions when L3 lifecycle protocol and reliable Runtime stop are available. Only the creator with current read access may admit it; accepted, running, cancelling or approval-waiting Turns return 409 turn_active. Admission persists a lifecycle-only execution fence and a versioned actor-scoped operation. SessionEnd settles before SessionDelete; committed Hook results are verified in Session Store and saved before permanent draining, attachment cleanup and original worker stop. Unknown outcomes or missing original stop evidence remain recovery_blocked and are never replayed on a replacement worker. CLOSED/ARCHIVED deletion retains L2: completed reliable-close evidence is required and no Harness or Hook is invoked. Completion atomically commits retirement, tombstone, receipt and terminal event, retaining shared Workspace files and neighboring holders. Authorized same-actor/key replays return the original operation, including after deletion; readable non-creators return 403. Missing lifecycle capability or close evidence returns 409 workspace_unavailable." } }, "/api/agent/web-shell/v1/workspaces/get": { @@ -4078,7 +4078,7 @@ "type": "boolean", "default": false, "x-qwen-implementation-status": "implemented", - "description": "Optional support for this retention operation. For a Workspace-bound Session requires completed reliable-close evidence; authorization and source-state validation still apply." + "description": "Optional support capability. ACTIVE Workspace files Sessions require L3 lifecycle protocol and reliable original Runtime stop; CLOSED/ARCHIVED Sessions use independent L2 close evidence. Current creator/read authorization and idle/source-state admission checks still apply. The aggregate session lifecycle capability is unchanged." } } }, @@ -5035,7 +5035,7 @@ "type": "boolean", "default": false, "x-qwen-implementation-status": "implemented", - "description": "Optional support for this retention operation. For a Workspace-bound Session requires completed reliable-close evidence; authorization and source-state validation still apply." + "description": "Optional support capability. ACTIVE Workspace files Sessions require L3 lifecycle protocol and reliable original Runtime stop; CLOSED/ARCHIVED Sessions use independent L2 close evidence. Current creator/read authorization and idle/source-state admission checks still apply. The aggregate session lifecycle capability is unchanged." } } }, diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java index 1e98fce23fc..9b26859d0f4 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionLifecycleTest.java @@ -672,15 +672,14 @@ void activeWorkspaceSessionsRejectRetentionAndUnsupportedClose() .principal(actor(tenant, "actor-a")), tenant, "bound-close") .andExpect(status().isConflict()) .andExpect(jsonPath("$.error.code").value("workspace_unavailable")); - for (MockHttpServletRequestBuilder request : List.of( - post("/v1/agents/sessions/{id}/archive", sessionId), - delete("/v1/agents/sessions/{id}", sessionId))) { - lifecycle(request.principal(actor(tenant, "actor-a")), tenant, - "bound-" + UUID.randomUUID()) - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.error.code") - .value("session_state_conflict")); - } + lifecycle(post("/v1/agents/sessions/{id}/archive", sessionId) + .principal(actor(tenant, "actor-a")), tenant, "bound-archive") + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("session_state_conflict")); + lifecycle(delete("/v1/agents/sessions/{id}", sessionId) + .principal(actor(tenant, "actor-a")), tenant, "bound-delete") + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("workspace_unavailable")); mvc.perform(post(WEB_SHELL + "/sessions/delete").header(TENANT, tenant) .principal(actor(tenant, "actor-b")) .contentType(MediaType.APPLICATION_JSON) diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionRetentionMySqlIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionRetentionMySqlIT.java index 964f30cf03a..aedde2f14a1 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionRetentionMySqlIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionRetentionMySqlIT.java @@ -345,6 +345,7 @@ void prerequisiteMigrationsPreserveExistingCloseEvidenceAndAllowRetirement() { properties.getHarness().setWorkspaceFilesEnabled(true); var store = new ManagedAgentStore(jdbc, mapper, Clock.systemUTC(), ignored -> {}, new ManagedWorkspaceRegistry(jdbc), properties); assertThat(store.hasCompletedWorkspaceClose(tenant, session)).isTrue(); + assertThat(store.findOperation(tenant, session, "legacy-close").orElseThrow().lifecycleProtocolVersion()).isZero(); assertThat(store.requireSession(tenant, session).status()).isEqualTo("CLOSED"); assertThat(store.requireSession(tenant, session).toolProfile()).isEqualTo("hosted-workspace-files/1"); assertThat(store.findOperation(tenant, session, "legacy-close").orElseThrow().receiptId()).isEqualTo("legacy-close-receipt"); diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/WorkspaceRuntimeTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/WorkspaceRuntimeTest.java index f0a84981367..3041fc895b7 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/WorkspaceRuntimeTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/WorkspaceRuntimeTest.java @@ -626,6 +626,34 @@ void hookLookupCannotUseAReplacedLeaseOrLostRuntime() throws Exception { authority.assertHeld(session.workspace(), fixture.record()); } + @Test + void legacyCloseHooksUseTheOriginalRuntimeAndStillCheckCurrentAuthorization() throws Exception { + SessionRecord session = createSession("storage", "."); + var fixture = transport(session); + var runtimeSession = fixture.record().getSession(); + authority.claim(session.workspace(), fixture.record()); + long now = System.currentTimeMillis(); + jdbc.update("UPDATE managed_agent_session SET status = 'CLOSING' WHERE tenant_id = ? AND session_id = ?", + session.tenantId(), session.sessionId()); + jdbc.update("INSERT INTO managed_agent_operation (tenant_id, session_id, operation_id, operation_kind, actor_digest," + + " idempotency_key, request_digest, state, admission_stage, delivery_state, session_status_before, lease_until," + + " available_at, created_at, updated_at) VALUES (?, ?, ?, 'CLOSE', 'actor', 'close', 'digest', 'PENDING'," + + " 'ACCEPTED', 'LEASED', 'ACTIVE', ?, ?, ?, ?)", session.tenantId(), session.sessionId(), UUID.randomUUID().toString(), + now + 60_000, now, now, now); + fixture.bindings().requestHarnessDrain(session.tenantId(), session.sessionId()); + when(fixture.http().control(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(Map.of("state", "settled"))); + for (String kind : List.of("hook-execute", "hook-catalog")) { + Map hook = hookControl(session, kind); + fixture.transport().control(fixture.lease(), runtimeSession, hook).toCompletableFuture().join(); + verify(fixture.http()).control(fixture.lease(), runtimeSession, hook); + } + assertUnavailable(() -> fixture.transport().control(fixture.lease(), runtimeSession, mcpControl(session, "mcp-configure"))); + jdbc.update("UPDATE managed_workspace_access SET can_read = FALSE WHERE tenant_id = ?", session.tenantId()); + assertUnavailable(() -> fixture.transport().control(fixture.lease(), runtimeSession, hookControl(session, "hook-execute"))); + fixture.transport().control(fixture.lease(), runtimeSession, hookControl(session, "hook-status")).toCompletableFuture().join(); + authority.assertHeld(session.workspace(), fixture.record()); + } + private static Map hookControl(SessionRecord session, String kind) { return Map.of("kind", kind, "operationId", kind, "sessionKey", Map.of("tenantId", session.tenantId(), diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleMySqlIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleMySqlIT.java new file mode 100644 index 00000000000..95fb6382bd1 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleMySqlIT.java @@ -0,0 +1,104 @@ +package com.alibaba.qwen.code.managedagent.store; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.alibaba.qwen.code.managedagent.api.ApiException; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.AcquireWriterRequest; +import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationKind; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +class WorkspaceLifecycleMySqlIT extends WorkspaceLifecycleStoreTest { + private DriverManagerDataSource source; + private JdbcTemplate admin; + private String schema; + + @BeforeEach + void openSchema() { + String url = System.getProperty("mysql.url"); + if (url == null || !url.matches("jdbc:mysql://[^/]+/[^?]+(?:\\?.*)?")) { + throw new IllegalArgumentException("A MySQL test database URL is required"); + } + String user = System.getProperty("mysql.user", "root"); + String password = System.getProperty("mysql.password", ""); + admin = new JdbcTemplate(new DriverManagerDataSource(url, user, password)); + schema = "l3_" + UUID.randomUUID().toString().replace("-", ""); + admin.execute("CREATE DATABASE " + schema); + source = new DriverManagerDataSource(url.replaceFirst("/[^/?]+(?=\\?|$)", "/" + schema), user, password); + } + + @AfterEach + void removeSchema() { + if (admin != null && schema != null) { + admin.execute("DROP DATABASE IF EXISTS " + schema); + } + } + + @Override + Fixture fixture() { + return new Fixture(source); + } + + @Test + void admissionSerializesAnOrdinaryWriterOnThePlacementFence() throws Exception { + var fixture = fixture(); + var journal = new ManagedSessionStore(fixture.jdbc); + try (var pool = Executors.newSingleThreadExecutor()) { + var entered = new CountDownLatch(1); + var queued = new java.util.concurrent.atomic.AtomicReference>(); + fixture.transactions.execute(ignored -> { + WorkspaceLifecycleStore.lockPlacement(fixture.jdbc, "tenant"); + queued.set(pool.submit(() -> { + entered.countDown(); + try { + fixture.transactions.execute(transaction -> journal.acquireWriter("tenant", fixture.session, + "w".repeat(32), new AcquireWriterRequest("workspace", "late-writer", 60_000L))); + return null; + } catch (RuntimeException error) { + return error; + } + })); + try { + assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue(); + assertThrows(TimeoutException.class, () -> queued.get().get(100, TimeUnit.MILLISECONDS)); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(error); + } + return fixture.admit(OperationKind.DELETE); + }); + assertThat(queued.get().get(5, TimeUnit.SECONDS)).isInstanceOf(ApiException.class); + } + assertThat(fixture.jdbc.queryForObject("SELECT COUNT(*) FROM qwen_managed_session_journal_head", Integer.class)).isZero(); + } + + @Test + void aFailureAfterRetirementRollsBackTheEntireDeleteAndRetryReusesEffects() { + var fixture = fixture(); + var operation = fixture.admit(OperationKind.DELETE); + var effects = fixture.transactions.execute(ignored -> fixture.lifecycle.recoverEffects(operation)); + fixture.jdbc.execute("CREATE TRIGGER l3_delete_failure BEFORE UPDATE ON managed_agent_session FOR EACH ROW" + + " BEGIN IF NEW.status = 'DELETED' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'l3 injected crash'; END IF; END"); + assertThatThrownBy(() -> fixture.transactions.execute(ignored -> fixture.store.completeOperation("tenant", fixture.session, + operation.operationId(), "worker", operation.claimGeneration(), true))).hasMessageContaining("l3 injected crash"); + assertThat(fixture.store.requireSession("tenant", fixture.session).status()).isEqualTo("DELETING"); + assertThat(fixture.jdbc.queryForObject("SELECT COUNT(*) FROM qwen_output_session_retirement", Integer.class)).isZero(); + assertThat(fixture.store.findOperation("tenant", fixture.session, operation.operationId()).orElseThrow().receiptId()).isNull(); + fixture.jdbc.execute("DROP TRIGGER l3_delete_failure"); + assertThat(fixture.transactions.execute(ignored -> fixture.lifecycle.recoverEffects(operation))) + .isEqualTo(effects); + assertThat(fixture.transactions.execute(ignored -> fixture.store.completeOperation("tenant", fixture.session, + operation.operationId(), "worker", operation.claimGeneration(), true))).isTrue(); + assertThat(fixture.jdbc.queryForObject("SELECT COUNT(*) FROM qwen_output_session_retirement", Integer.class)).isOne(); + } +} diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java new file mode 100644 index 00000000000..849dc2b6e53 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java @@ -0,0 +1,221 @@ +package com.alibaba.qwen.code.managedagent.store; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.alibaba.qwen.code.managedagent.api.WorkspaceSelection; +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationKind; +import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationRecord; +import com.alibaba.qwen.code.runtimebroker.AesGcmSecretProtector; +import com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository; +import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRepository; +import com.alibaba.qwen.code.runtimebroker.RuntimeProvisionRequest; +import com.alibaba.qwen.code.runtimebroker.RuntimeScope; +import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import org.flywaydb.core.Flyway; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +class WorkspaceLifecycleStoreTest { + @Test + void legacyCloseKeepsItsOriginalHookPathButCannotAdmitOrdinaryWork() { + var fixture = fixture(); + var journal = new ManagedSessionStore(fixture.jdbc); + var writer = fixture.transactions.execute(ignored -> journal.acquireWriter("tenant", fixture.session, + "w".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "original", 60_000L))); + String id = fixture.transactions.execute(ignored -> fixture.store.beginWorkspaceLifecycle("tenant", fixture.session, + OperationKind.CLOSE, "owner", "a".repeat(64), "legacy", "digest", true).operation().operationId()); + var operation = fixture.transactions.execute(ignored -> fixture.store.claimOperation("tenant", fixture.session, id, + "worker", Duration.ofSeconds(30)).orElseThrow()); + fixture.bindings.requestHarnessDrain("tenant", fixture.session); + assertThat(operation.lifecycleProtocolVersion()).isZero(); + var ordinary = new ManagedSessionStoreModels.AuthorizeLifecycleRequest("workspace", "original", writer.writerGeneration()); + var legacy = new ManagedSessionStoreModels.AuthorizeLifecycleRequest("workspace", "original", writer.writerGeneration(), "legacy-close"); + assertThatThrownBy(() -> fixture.transactions.executeWithoutResult(ignored -> journal.authorizeOrdinary("tenant", fixture.session, + "w".repeat(32), ordinary))).hasMessageContaining("admission is closed"); + fixture.transactions.executeWithoutResult(ignored -> journal.authorizeOrdinary("tenant", fixture.session, "w".repeat(32), legacy)); + fixture.bindings.requireHookAdmission(fixture.scope(), fixture.session, null); + assertThatThrownBy(() -> fixture.bindings.requireHarnessAdmission(fixture.scope(), fixture.session, null)) + .hasMessageContaining("closed"); + var execution = new WorkspaceExecutionStore(fixture.jdbc, new DataSourceTransactionManager(fixture.jdbc.getDataSource())); + execution.authorizeLegacyClose(fixture.store.requireSession("tenant", fixture.session)); + fixture.jdbc.update("UPDATE managed_workspace_access SET can_create = FALSE"); + assertThatThrownBy(() -> execution.authorizeLegacyClose(fixture.store.requireSession("tenant", fixture.session))) + .hasMessageContaining("unavailable"); + fixture.jdbc.update("UPDATE managed_workspace_access SET can_create = TRUE"); + execution.authorizeLegacyClose(fixture.store.requireSession("tenant", fixture.session)); + fixture.jdbc.update("UPDATE managed_agent_operation SET lease_until = 0"); + assertThatThrownBy(() -> fixture.transactions.executeWithoutResult(ignored -> journal.authorizeOrdinary("tenant", fixture.session, + "w".repeat(32), legacy))).hasMessageContaining("admission is closed"); + } + + @Test + void l3FenceCannotUseTheLegacyCloseException() { + var fixture = fixture(); + var journal = new ManagedSessionStore(fixture.jdbc); + var writer = fixture.transactions.execute(ignored -> journal.acquireWriter("tenant", fixture.session, + "w".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "original", 60_000L))); + fixture.admit(OperationKind.CLOSE); + var legacy = new ManagedSessionStoreModels.AuthorizeLifecycleRequest("workspace", "original", writer.writerGeneration(), "legacy-close"); + assertThatThrownBy(() -> fixture.transactions.executeWithoutResult(ignored -> journal.authorizeOrdinary("tenant", fixture.session, + "w".repeat(32), legacy))).hasMessageContaining("admission is closed"); + assertThatThrownBy(() -> fixture.bindings.requireHookAdmission(fixture.scope(), fixture.session, null)).hasMessageContaining("closed"); + fixture.bindings.requestHarnessDrain("tenant", fixture.session); + assertThatThrownBy(() -> fixture.bindings.requireHookAdmission(fixture.scope(), fixture.session, null)).hasMessageContaining("closed"); + } + @Test + void lifecycleAttachmentCannotDetachBeforeEffectsAreSaved() { + var fixture = fixture(); + var journal = new ManagedSessionStore(fixture.jdbc); + var writer = fixture.transactions.execute(ignored -> journal.acquireWriter("tenant", fixture.session, + "w".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "original", 60_000L))); + var operation = fixture.admit(OperationKind.DELETE); + var unverified = new ObjectMapper().createObjectNode().put("protocolVersion", 1) + .put("operationId", operation.operationId()).put("kind", "delete").put("neverInitialized", true); + unverified.putObject("sessionKey").put("tenantId", "tenant").put("workspaceId", "workspace").put("sessionId", fixture.session); + unverified.putArray("effects"); + assertThatThrownBy(() -> fixture.transactions.executeWithoutResult(ignored -> fixture.lifecycle.saveEffects(operation, unverified))) + .hasMessageContaining("blocked"); + var request = new ManagedSessionStoreModels.AuthorizeLifecycleRequest("workspace", "original", writer.writerGeneration()); + fixture.transactions.executeWithoutResult(ignored -> journal.authorizeLifecycle("tenant", fixture.session, + "w".repeat(32), new ManagedSessionStoreModels.AuthorizeLifecycleRequest("workspace", "original", writer.writerGeneration(), "delete"), + WorkspaceLifecycleStore.authority(operation))); + assertThatThrownBy(() -> fixture.transactions.executeWithoutResult(ignored -> journal.authorizeLifecycle("tenant", fixture.session, + "w".repeat(32), request, WorkspaceLifecycleStore.authority(operation)))).hasMessageContaining("blocked"); + assertThat(journal.hasLiveWriter("tenant", fixture.session)).isTrue(); + assertThat(fixture.jdbc.queryForObject("SELECT phase FROM qwen_runtime_harness_drain", String.class)).isEqualTo("LIFECYCLE_ONLY"); + } + + @Test + void completedBootstrapWithoutTheOriginalHeaderIsNotNeverInitializedEvidence() { + var fixture = fixture(); + var operation = fixture.admit(OperationKind.DELETE); + fixture.jdbc.update("UPDATE managed_agent_session SET harness_boot_id = 'original-boot'"); + assertThat(fixture.transactions.execute(ignored -> fixture.lifecycle.recoverEffects(operation))).isNull(); + assertThat(fixture.jdbc.queryForObject("SELECT lifecycle_effects_receipt_json FROM managed_agent_operation", String.class)).isNull(); + assertThat(fixture.jdbc.queryForObject("SELECT phase FROM qwen_runtime_harness_drain", String.class)).isEqualTo("LIFECYCLE_ONLY"); + } + + @Test + void emptySessionCompletesAtomicallyOnlyAfterEffectsAndPermanentFence() { + var fixture = fixture(); + OperationRecord operation = fixture.admit(OperationKind.DELETE); + assertThat(fixture.jdbc.queryForObject("SELECT phase FROM qwen_runtime_harness_drain", String.class)) + .isEqualTo("LIFECYCLE_ONLY"); + assertThatThrownBy(() -> fixture.transactions.execute(ignored -> fixture.store.completeOperation( + "tenant", fixture.session, operation.operationId(), "worker", operation.claimGeneration(), true))) + .hasMessageContaining("blocked"); + var effects = fixture.transactions.execute(ignored -> fixture.lifecycle.recoverEffects(operation)); + assertThat(effects.path("neverInitialized").asBoolean()).isTrue(); + assertThat(fixture.jdbc.queryForObject("SELECT receipt_id FROM managed_agent_operation", String.class)).isNull(); + assertThat(fixture.jdbc.queryForObject("SELECT phase FROM qwen_runtime_harness_drain", String.class)) + .isEqualTo("DRAINING"); + assertThat(fixture.transactions.execute(ignored -> fixture.store.completeOperation( + "tenant", fixture.session, operation.operationId(), "worker", operation.claimGeneration(), true))).isTrue(); + assertThat(fixture.store.requireSession("tenant", fixture.session).status()).isEqualTo("DELETED"); + assertThat(fixture.jdbc.queryForObject("SELECT COUNT(*) FROM qwen_output_session_retirement", Integer.class)).isOne(); + assertThat(fixture.store.findOperation("tenant", fixture.session, operation.operationId()).orElseThrow().receiptId()).isNotNull(); + } + + @Test + void expiredClaimCannotSaveEffectsOrAdmitRuntimeAndTakeoverKeepsOperationIdentity() { + var fixture = fixture(); + OperationRecord first = fixture.admit(OperationKind.DELETE); + RuntimeScope scope = fixture.scope(); + fixture.bindings.requireHarnessAdmission(scope, fixture.session, WorkspaceLifecycleStore.authority(first)); + assertThatThrownBy(() -> fixture.bindings.requireHarnessAdmission(scope, fixture.session, null)) + .hasMessageContaining("closed"); + fixture.jdbc.update("UPDATE managed_agent_operation SET lease_until = 0"); + OperationRecord second = fixture.transactions.execute(ignored -> fixture.store.claimOperation( + "tenant", fixture.session, first.operationId(), "second", Duration.ofSeconds(30)).orElseThrow()); + assertThat(second.claimGeneration()).isGreaterThan(first.claimGeneration()); + assertThatThrownBy(() -> fixture.bindings.requireHarnessAdmission(scope, fixture.session, WorkspaceLifecycleStore.authority(first))) + .hasMessageContaining("closed"); + assertThatThrownBy(() -> fixture.transactions.execute(ignored -> fixture.lifecycle.recoverEffects(first))) + .hasMessageContaining("blocked"); + fixture.bindings.requireHarnessAdmission(scope, fixture.session, WorkspaceLifecycleStore.authority(second)); + assertThat(fixture.transactions.execute(ignored -> fixture.lifecycle.recoverEffects(second))).isNotNull(); + assertThat(fixture.transactions.execute(ignored -> fixture.store.completeOperation( + "tenant", fixture.session, first.operationId(), "worker", first.claimGeneration(), true))).isFalse(); + } + + @Test + void releasedBindingWithoutOriginalStopProofCannotComplete() { + var fixture = fixture(); + var binding = fixture.bindings.findOrCreate(new RuntimeProvisionRequest(fixture.scope(), fixture.session)); + fixture.jdbc.update("UPDATE qwen_runtime_binding SET binding_state = 'RELEASED' WHERE binding_id = ?", binding.getBindingId()); + OperationRecord operation = fixture.admit(OperationKind.DELETE); + fixture.transactions.execute(ignored -> fixture.lifecycle.recoverEffects(operation)); + assertThatThrownBy(() -> fixture.transactions.execute(ignored -> fixture.store.completeOperation( + "tenant", fixture.session, operation.operationId(), "worker", operation.claimGeneration(), true))) + .hasMessageContaining("proof"); + assertThat(fixture.store.requireSession("tenant", fixture.session).status()).isEqualTo("DELETING"); + assertThat(fixture.jdbc.queryForObject("SELECT COUNT(*) FROM qwen_output_session_retirement", Integer.class)).isZero(); + } + + Fixture fixture() { + return new Fixture(); + } + + static final class Fixture { + final JdbcTemplate jdbc; + final TransactionTemplate transactions; + final ManagedAgentStore store; + final WorkspaceLifecycleStore lifecycle; + final JdbcRuntimeBindingRepository bindings; + final String session; + + Fixture() { + this(h2()); + } + + private static javax.sql.DataSource h2() { + var source = new JdbcDataSource(); + source.setURL("jdbc:h2:mem:l3-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE"); + return source; + } + + Fixture(javax.sql.DataSource source) { + Flyway.configure().dataSource(source).locations("classpath:db/migration").load().migrate(); + jdbc = new JdbcTemplate(source); + transactions = new TransactionTemplate(new DataSourceTransactionManager(source)); + var json = new ObjectMapper(); + var properties = new ManagedAgentProperties(); + properties.getHarness().setWorkspaceFilesEnabled(true); + store = new ManagedAgentStore(jdbc, json, Clock.systemUTC(), ignored -> {}, new ManagedWorkspaceRegistry(jdbc), properties); + bindings = new JdbcRuntimeBindingRepository(source, new AesGcmSecretProtector("test", new byte[32])); + var beans = new DefaultListableBeanFactory(); + beans.registerSingleton("bindings", bindings); + lifecycle = new WorkspaceLifecycleStore(jdbc, json, beans.getBeanProvider(RuntimeBindingRepository.class)); + store.setWorkspaceLifecycleStore(lifecycle); + jdbc.update("INSERT INTO managed_workspace_registry (tenant_id, workspace_id, workspace_generation, storage_id," + + " display_name, config_ref, policy_ref, state) VALUES ('tenant', 'workspace', 1, 'storage', 'Workspace', ?, ?, 'ACTIVE')", + WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF); + jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)" + + " VALUES ('tenant', 'workspace', ?, TRUE, TRUE)", "owner".getBytes(StandardCharsets.UTF_8)); + session = transactions.execute(ignored -> store.insertWorkspaceSessionCommand("tenant", "owner", "create", "digest", "qwen-code", + null, null, List.of(), null, new WorkspaceSelection("workspace", ".")).sessionId()); + } + + RuntimeScope scope() { + return new RuntimeScope("tenant", "workspace", "1", "/workspace", "digest", "session"); + } + + OperationRecord admit(OperationKind kind) { + String id = transactions.execute(ignored -> store.beginWorkspaceLifecycle("tenant", session, kind, "owner", "a".repeat(64), + "operation", "digest", true, 1).operation().operationId()); + return transactions.execute(ignored -> store.claimOperation("tenant", session, id, "worker", Duration.ofSeconds(30)).orElseThrow()); + } + } +} diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessCapabilities.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessCapabilities.java index 33d25457389..6712a48aec2 100644 --- a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessCapabilities.java +++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessCapabilities.java @@ -10,10 +10,19 @@ public final class HostedHarnessCapabilities { private final List supportedProtocolVersions; private final String bootId; private final String capabilityDigest; + private final int lifecycleProtocolVersion; + + public int getLifecycleProtocolVersion() { return lifecycleProtocolVersion; } HostedHarnessCapabilities(int currentProtocolVersion, List supportedProtocolVersions, String bootId, String capabilityDigest) { + this(currentProtocolVersion, supportedProtocolVersions, bootId, capabilityDigest, 0); + } + + HostedHarnessCapabilities(int currentProtocolVersion, List supportedProtocolVersions, + String bootId, String capabilityDigest, int lifecycleProtocolVersion) { + this.lifecycleProtocolVersion = lifecycleProtocolVersion; this.currentProtocolVersion = currentProtocolVersion; this.supportedProtocolVersions = Collections.unmodifiableList( new ArrayList<>(supportedProtocolVersions)); diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java index f1dd240578c..14beff40755 100644 --- a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java +++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java @@ -521,6 +521,31 @@ public HarnessTranscriptPage getTranscript(GetHarnessTranscript request) { json); } + public Map settleLifecycle(HarnessSessionRef session, Map request) { + HarnessSessionRef ref = requireSessionRef(session); + if (capabilities.getLifecycleProtocolVersion() != 1) { + throw new DaemonProtocolException("Hosted lifecycle protocol 1 is required"); + } + String operation = "POST /session/:id/lifecycle"; + HttpSupport.Response response = sendMutation(sessionPath(ref.getHarnessSessionId()) + "/lifecycle", + request, ref.getHarnessClientId(), operation); + requireMutationStatus(response, 200, operation); + Map receipt = JsonSupport.parseObject(response.getBody(), operation); + if (!java.util.Objects.equals(request.get("sessionKey"), receipt.get("sessionKey")) + || !java.util.Objects.equals(request.get("kind"), receipt.get("kind"))) { + throw new DaemonProtocolException("Hosted lifecycle receipt identity differs"); + } + return receipt; + } + + public void detachLifecycle(HarnessSessionRef session, Map authority) { + HarnessSessionRef ref = requireSessionRef(session); + HttpSupport.Response response = sendMutation(sessionPath(ref.getHarnessSessionId()) + "/detach", + Map.of("authority", authority), ref.getHarnessClientId(), "POST /session/:id/detach"); + requireMutationStatus(response, 204, "POST /session/:id/detach"); + removeAttachment(ref); + } + public void detachSession(HarnessSessionRef session) { HarnessSessionRef ref = requireSessionRef(session); HttpSupport.Response response = sendMutation( @@ -750,7 +775,8 @@ private HostedHarnessCapabilities negotiateCapabilities( expectedDigest, digest); } return new HostedHarnessCapabilities(current, supported, bootId, - digest); + digest, hosted.containsKey("lifecycleProtocolVersion") + ? JsonSupport.requiredInt(hosted, "lifecycleProtocolVersion", "capabilities.hostedHarness") : 0); } private HarnessSessionRef parseSession(String body, diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/LoadHarnessSession.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/LoadHarnessSession.java index 1b3cbbf51ff..5ebcefb8a4d 100644 --- a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/LoadHarnessSession.java +++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/LoadHarnessSession.java @@ -10,6 +10,17 @@ public final class LoadHarnessSession { private final boolean passiveManagedRuntimeRecovery; private final String toolProfile; private final boolean driveRuntimeRecovery; + private Map lifecycleAuthority; + + public LoadHarnessSession forLifecycle(String operationId, long claimGeneration) { + if (operationId == null || !operationId.matches("[A-Za-z0-9._:-]{1,128}") || claimGeneration < 1) { + throw new IllegalArgumentException("Invalid lifecycle authority"); + } + LoadHarnessSession copy = new LoadHarnessSession(harnessSessionId, managedSessionStore, + passiveManagedRuntimeRecovery, toolProfile, driveRuntimeRecovery); + copy.lifecycleAuthority = Map.of("operationId", operationId, "claimGeneration", claimGeneration); + return copy; + } public LoadHarnessSession(String harnessSessionId) { this(harnessSessionId, null, false); @@ -63,6 +74,9 @@ Map toJson() { if (driveRuntimeRecovery) { result.put("driveRuntimeRecovery", true); } + if (lifecycleAuthority != null) { + result.put("lifecycleAuthority", lifecycleAuthority); + } return result; } } diff --git a/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java index 6945a68bb5d..d692b9deed9 100644 --- a/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java +++ b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java @@ -69,6 +69,55 @@ void tearDown() { } } + @Test + void lifecycleSettlementRetainsAttachmentAndDetachCarriesTheNewClaim() { + server.removeContext("/capabilities"); + server.createContext("/capabilities", exchange -> sendJson(exchange, 200, + capabilitiesJson(DIGEST, BOOT_ID).replace("\"hostedHarness\":{", "\"hostedHarness\":{\"lifecycleProtocolVersion\":1,"), false)); + createSessionRoute(); + AtomicReference> settlement = new AtomicReference<>(); + AtomicReference> detach = new AtomicReference<>(); + server.createContext("/session/" + SESSION_ID + "/lifecycle", exchange -> { + assertEquals("POST", exchange.getRequestMethod()); + assertEquals(CLIENT_ID, exchange.getRequestHeaders().getFirst(HostedHarnessClient.CLIENT_ID_HEADER)); + settlement.set(JsonSupport.parseObject(readBody(exchange), "lifecycle")); + sendSessionJson(exchange, 200, "{\"protocolVersion\":1,\"operationId\":\"delete-1\",\"kind\":\"delete\"," + + "\"sessionKey\":{\"tenantId\":\"tenant\",\"workspaceId\":\"workspace\",\"sessionId\":\"" + SESSION_ID + "\"},\"effects\":[]}"); + }); + server.createContext("/session/" + SESSION_ID + "/detach", exchange -> { + detach.set(JsonSupport.parseObject(readBody(exchange), "detach")); + sendSessionNoContent(exchange); + }); + try (HostedHarnessClient client = newClient()) { + var session = createSession(client); + var authority = Map.of("operationId", "delete-1", "claimGeneration", 2); + var request = Map.of("kind", "delete", "sessionKey", Map.of("tenantId", "tenant", "workspaceId", "workspace", + "sessionId", SESSION_ID), "authority", authority); + assertEquals(1, client.capabilities().getLifecycleProtocolVersion()); + assertEquals("delete-1", client.settleLifecycle(session, request).get("operationId")); + assertEquals(request, settlement.get()); + assertNull(detach.get()); + client.detachLifecycle(session, authority); + assertEquals(Map.of("authority", authority), detach.get()); + } + } + + @Test + void legacyHarnessCannotFallBackToDeleteForLifecycleSettlement() { + AtomicInteger requests = new AtomicInteger(); + createSessionRoute(); + server.createContext("/session/" + SESSION_ID, exchange -> { + requests.incrementAndGet(); + sendSessionNoContent(exchange); + }); + try (HostedHarnessClient client = newClient()) { + var session = createSession(client); + assertEquals(0, client.capabilities().getLifecycleProtocolVersion()); + assertThrows(DaemonProtocolException.class, () -> client.settleLifecycle(session, Map.of())); + assertEquals(0, requests.get()); + } + } + @Test void negotiatesAndFencesSessionCreation() { AtomicReference authorization = new AtomicReference<>(); diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/HarnessSessionResolver.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/HarnessSessionResolver.java index 7533a409f90..604d689c9f1 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/HarnessSessionResolver.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/HarnessSessionResolver.java @@ -11,4 +11,12 @@ */ public interface HarnessSessionResolver { CompletionStage resolve(String harnessSessionId); + + default CompletionStage resolve(String harnessSessionId, RuntimeLifecycleAuthority authority) { + if (authority != null) { + return java.util.concurrent.CompletableFuture.failedFuture(new RuntimeBrokerException( + 409, "runtime_lifecycle_unavailable", "Lifecycle authority is unavailable", false)); + } + return resolve(harnessSessionId); + } } diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/InMemoryRuntimeBindingRepository.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/InMemoryRuntimeBindingRepository.java index 23bdb48fdae..5a7bb8f86aa 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/InMemoryRuntimeBindingRepository.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/InMemoryRuntimeBindingRepository.java @@ -13,6 +13,13 @@ /** Process-local Runtime binding repository for tests and single-node use. */ public final class InMemoryRuntimeBindingRepository implements RuntimeBindingRepository { + @Override + public void requireHookAdmission(RuntimeScope scope, String harnessSessionId, + RuntimeLifecycleAuthority authority) { + if (authority != null) { + requireHarnessAdmission(scope, harnessSessionId, authority); + } + } private final java.util.Set> draining = new java.util.HashSet<>(); private final Clock clock; private final Supplier idSupplier; diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/JdbcRuntimeBindingRepository.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/JdbcRuntimeBindingRepository.java index 4f4de2b9537..fd937180881 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/JdbcRuntimeBindingRepository.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/JdbcRuntimeBindingRepository.java @@ -202,7 +202,8 @@ public RuntimeSessionRecord admitSession(RuntimeSessionRepository sessions, RuntimeBindingRecord binding = selectById(connection, candidate.getBindingId(), true); if (binding != null) { - requireHarnessAdmission(connection, binding.getRequest()); + requireHarnessAdmission(connection, candidate.getSession().getScope(), candidate.getSession().getHarnessSessionId(), + candidate.getSession().getScope().getLifecycleAuthority()); } RuntimeAdmission.requireReady(binding, candidate.getRuntimeGeneration()); RuntimeScope scope = candidate.getSession().getScope(); @@ -281,7 +282,7 @@ public void requestHarnessDrain(String tenantId, String harnessSessionId) { lockPlacementDomain(connection, tenantId); try (PreparedStatement statement = connection.prepareStatement( "INSERT INTO qwen_runtime_harness_drain (tenant_key, harness_key, tenant_id, harness_session_id) VALUES (?, ?, ?, ?)" - + " ON DUPLICATE KEY UPDATE harness_session_id = harness_session_id")) { + + " ON DUPLICATE KEY UPDATE phase = 'DRAINING', claim_lease_until = NULL")) { statement.setString(1, JdbcRepositorySupport.valueKey(tenantId)); statement.setString(2, JdbcRepositorySupport.valueKey(harnessSessionId)); statement.setString(3, tenantId); @@ -294,6 +295,20 @@ public void requestHarnessDrain(String tenantId, String harnessSessionId) { @Override public boolean isHarnessDraining(String tenantId, String harnessSessionId) { + return JdbcRepositorySupport.read(dataSource, connection -> { + try (PreparedStatement statement = connection.prepareStatement("SELECT phase FROM qwen_runtime_harness_drain" + + " WHERE tenant_key = ? AND harness_key = ?")) { + statement.setString(1, JdbcRepositorySupport.valueKey(tenantId)); + statement.setString(2, JdbcRepositorySupport.valueKey(harnessSessionId)); + try (ResultSet row = statement.executeQuery()) { + return row.next() && "DRAINING".equals(row.getString(1)); + } + } + }); + } + + @Override + public boolean isHarnessAdmissionClosed(String tenantId, String harnessSessionId) { return JdbcRepositorySupport.read(dataSource, connection -> hasHarnessDrain(connection, tenantId, harnessSessionId)); } @@ -318,10 +333,87 @@ private static boolean hasHarnessDrain(Connection connection, String tenantId, S private static void requireHarnessAdmission(Connection connection, RuntimeProvisionRequest request) throws SQLException { - if ("session".equals(request.getScope().getIsolationClass()) - && hasHarnessDrain(connection, request.getScope().getTenantId(), request.getIsolationKey())) { - throw new RuntimeBrokerException(409, "runtime_admission_closed", "Session is draining.", false); + requireHarnessAdmission(connection, request.getScope(), request.getIsolationKey(), + request.getScope().getLifecycleAuthority()); + } + + @Override + public void requireHarnessAdmission(RuntimeScope scope, String harnessSessionId, RuntimeLifecycleAuthority authority) { + JdbcRepositorySupport.transaction(dataSource, connection -> { + lockPlacementDomain(connection, scope.getTenantId()); + requireHarnessAdmission(connection, scope, harnessSessionId, authority); + return null; + }); + } + + @Override + public void requireHookAdmission(RuntimeScope scope, String harnessSessionId, RuntimeLifecycleAuthority authority) { + JdbcRepositorySupport.transaction(dataSource, connection -> { + lockPlacementDomain(connection, scope.getTenantId()); + requireHarnessAdmission(connection, scope, harnessSessionId, authority, true); + return null; + }); + } + + public static void requireHarnessAdmission(Connection connection, RuntimeScope scope, + String harnessSessionId, RuntimeLifecycleAuthority authority) throws SQLException { + requireHarnessAdmission(connection, scope, harnessSessionId, authority, false); + } + + private static void requireHarnessAdmission(Connection connection, RuntimeScope scope, + String harnessSessionId, RuntimeLifecycleAuthority authority, boolean legacyHook) throws SQLException { + if (!"session".equals(scope.getIsolationClass())) { + if (authority != null) { + throw admissionClosed(); + } + return; } + try (PreparedStatement statement = connection.prepareStatement("SELECT tenant_id, harness_session_id, phase," + + " operation_id, claim_generation, claim_lease_until FROM qwen_runtime_harness_drain" + + " WHERE tenant_key = ? AND harness_key = ?")) { + statement.setString(1, JdbcRepositorySupport.valueKey(scope.getTenantId())); + statement.setString(2, JdbcRepositorySupport.valueKey(harnessSessionId)); + try (ResultSet row = statement.executeQuery()) { + if (!row.next()) { + if (authority != null) { + throw admissionClosed(); + } + return; + } + if (!scope.getTenantId().equals(row.getString("tenant_id")) + || !harnessSessionId.equals(row.getString("harness_session_id"))) { + throw admissionClosed(); + } + if (legacyHook && authority == null && "DRAINING".equals(row.getString("phase")) + && row.getString("operation_id") == null) { + return; + } + if (authority == null || !"LIFECYCLE_ONLY".equals(row.getString("phase")) + || !authority.operationId().equals(row.getString("operation_id")) + || authority.claimGeneration() != row.getLong("claim_generation") + || row.getLong("claim_lease_until") <= JdbcRepositorySupport.databaseNow(connection).toEpochMilli()) { + throw admissionClosed(); + } + } + } + } + + public static void beginHarnessLifecycle(Connection connection, String tenantId, String sessionId, String operationId) + throws SQLException { + try (PreparedStatement statement = connection.prepareStatement("INSERT INTO qwen_runtime_harness_drain" + + " (tenant_key, harness_key, tenant_id, harness_session_id, phase, operation_id)" + + " VALUES (?, ?, ?, ?, 'LIFECYCLE_ONLY', ?)")) { + statement.setString(1, JdbcRepositorySupport.valueKey(tenantId)); + statement.setString(2, JdbcRepositorySupport.valueKey(sessionId)); + statement.setString(3, tenantId); + statement.setString(4, sessionId); + statement.setString(5, operationId); + statement.executeUpdate(); + } + } + + private static RuntimeBrokerException admissionClosed() { + return new RuntimeBrokerException(409, "runtime_admission_closed", "Session admission is closed", false); } @Override @@ -353,6 +445,45 @@ public List findByHarnessSession(String tenantId, String h }); } + @Override + public void verifyHarnessStopped(Connection connection, String tenantId, String harnessSessionId) throws SQLException { + lockPlacementDomain(connection, tenantId); + try (PreparedStatement fence = connection.prepareStatement("SELECT tenant_id, harness_session_id, phase" + + " FROM qwen_runtime_harness_drain WHERE tenant_key = ? AND harness_key = ? FOR UPDATE")) { + fence.setString(1, JdbcRepositorySupport.valueKey(tenantId)); + fence.setString(2, JdbcRepositorySupport.valueKey(harnessSessionId)); + try (ResultSet row = fence.executeQuery()) { + if (!row.next() || !tenantId.equals(row.getString(1)) || !harnessSessionId.equals(row.getString(2)) + || !"DRAINING".equals(row.getString(3))) { + throw admissionClosed(); + } + } + } + try (PreparedStatement statement = connection.prepareStatement("SELECT " + BINDING_COLUMNS + + " FROM qwen_runtime_binding WHERE tenant_id = ? AND isolation_class = 'session'" + + " AND isolation_key = ? ORDER BY binding_id FOR UPDATE")) { + statement.setString(1, tenantId); + statement.setString(2, harnessSessionId); + try (ResultSet rows = statement.executeQuery()) { + while (rows.next()) { + RuntimeBindingRecord record = mapBinding(rows); + if (!tenantId.equals(record.getRequest().getScope().getTenantId()) + || !harnessSessionId.equals(record.getRequest().getIsolationKey())) { + continue; + } + if (record.getState() != RuntimeBindingRecord.State.RELEASED + || record.getDrainReceipt() == null && !record.hasStoppedWriters()) { + throw new RuntimeBrokerException(409, "workspace_close_identity_unverified", "Original stop proof is missing", false); + } + if (JdbcRuntimeSessionRepository.hasActiveByBinding(connection, record) + || JdbcToolExecutionRepository.hasActiveByBinding(connection, record.getBindingId(), record.getGeneration())) { + throw new RuntimeBrokerException(409, "workspace_close_execution_unsettled", "Original resources remain active", false); + } + } + } + } + } + @Override public RuntimeBindingRecord findOrCreate( RuntimeProvisionRequest request) { @@ -375,6 +506,9 @@ public RuntimeBindingRecord findOrCreate( return active; } + if (request.getScope().getLifecycleAuthority() != null && slot.lastGeneration != 0) { + throw new RuntimeBrokerException(409, "workspace_close_identity_unverified", "Lifecycle cannot replace an original Runtime", false); + } requireRecoverablePlacement(connection, request); long generation = slot.lastGeneration + 1; String bindingId = BrokerValues.requireId(idSupplier.get(), diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/JdbcRuntimeBrokerSchema.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/JdbcRuntimeBrokerSchema.java index dfab5de38e9..59a6c7edfc3 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/JdbcRuntimeBrokerSchema.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/JdbcRuntimeBrokerSchema.java @@ -33,6 +33,10 @@ public static void initialize(DataSource dataSource) { addColumn(statement, "qwen_runtime_binding", "loss_evidence_json", "LONGTEXT"); addColumn(statement, "qwen_runtime_binding", "stop_evidence_json", "LONGTEXT"); addColumn(statement, "qwen_runtime_binding", "drain_receipt_json", "LONGTEXT"); + addColumn(statement, "qwen_runtime_harness_drain", "phase", "VARCHAR(32) NOT NULL DEFAULT 'DRAINING'"); + addColumn(statement, "qwen_runtime_harness_drain", "operation_id", "VARCHAR(128)"); + addColumn(statement, "qwen_runtime_harness_drain", "claim_generation", "BIGINT NOT NULL DEFAULT 0"); + addColumn(statement, "qwen_runtime_harness_drain", "claim_lease_until", "BIGINT"); addColumn(statement, "qwen_tool_execution", "abandoned_at", "DATETIME(6)"); addColumn(statement, "qwen_tool_execution", "loss_evidence_id", "VARCHAR(512)"); } catch (SQLException exception) { diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBindingRecord.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBindingRecord.java index 7ad1fdaa338..3685e4e7c80 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBindingRecord.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBindingRecord.java @@ -238,6 +238,15 @@ public RuntimeProvisionRequest getRequest() { return request; } + RuntimeBindingRecord withLifecycleAuthority(RuntimeLifecycleAuthority authority) { + RuntimeProvisionRequest scoped = new RuntimeProvisionRequest(request.getScope().withLifecycleAuthority(authority), + request.getIsolationKey(), request.getProvisionerKind(), request.getStorageId()); + return new RuntimeBindingRecord(bindingId, scoped, provisionSeed, generation, state, lease, + resourceHandle, attestationGeneration, drainRequested, operationOwner, operationLeaseUntil, + operationGeneration, version, lastHealthAt, lastReconciledAt, lastActiveAt, + lossEvidence, stopEvidence, drainReceipt); + } + public RuntimeProvisionSeed getProvisionSeed() { return provisionSeed; } diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBindingRepository.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBindingRepository.java index ac9a3295524..23fb116d99b 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBindingRepository.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBindingRepository.java @@ -47,6 +47,27 @@ RuntimeSessionRecord beginSessionRelease(RuntimeSessionRepository sessions, boolean isHarnessDraining(String tenantId, String harnessSessionId); + default boolean isHarnessAdmissionClosed(String tenantId, String harnessSessionId) { + return isHarnessDraining(tenantId, harnessSessionId); + } + + default void requireHarnessAdmission(RuntimeScope scope, String harnessSessionId, + RuntimeLifecycleAuthority authority) { + if (authority != null || isHarnessDraining(scope.getTenantId(), harnessSessionId)) { + throw new RuntimeBrokerException(409, "runtime_admission_closed", "Session admission is closed", false); + } + } + + default void requireHookAdmission(RuntimeScope scope, String harnessSessionId, + RuntimeLifecycleAuthority authority) { + requireHarnessAdmission(scope, harnessSessionId, authority); + } + + default void verifyHarnessStopped(java.sql.Connection connection, String tenantId, String harnessSessionId) + throws java.sql.SQLException { + throw new RuntimeBrokerException(409, "workspace_close_identity_unverified", "Stop verification is unavailable", false); + } + List findByHarnessSession(String tenantId, String harnessSessionId, String afterBindingId, int limit); diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServer.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServer.java index a023f7c062f..b222a7c1716 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServer.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServer.java @@ -110,10 +110,19 @@ private void handle(HttpExchange exchange) { requireProtocol(body); JsonCodec.requiredString(body, "requestId", "warm request"); String sessionId = JsonCodec.requiredString(body, "harnessSessionId", "warm request"); - complete(exchange, service.warm(sessionId), ignored -> Map.of( + complete(exchange, service.warm(sessionId, lifecycleAuthority(exchange)), ignored -> Map.of( "protocolVersion", 1, "harnessSessionId", sessionId, "ready", true)); return; } + if ("POST".equals(exchange.getRequestMethod()) && "/runtimes:authorize-lifecycle".equals(relative)) { + Map body = requestBody(exchange, "lifecycle authorization"); + requireProtocol(body); + String sessionId = JsonCodec.requiredString(body, "harnessSessionId", "lifecycle authorization"); + String runtimeSessionId = JsonCodec.requiredString(body, "runtimeSessionId", "lifecycle authorization"); + complete(exchange, service.authorizeLifecycle(sessionId, lifecycleAuthority(exchange)), + ignored -> envelope(sessionId, runtimeSessionId, "authorized", true)); + return; + } if ("POST".equals(exchange.getRequestMethod()) && "/tool-sessions:acquire".equals(relative)) { acquire(exchange); @@ -145,6 +154,19 @@ private void handle(HttpExchange exchange) { } } + private static RuntimeLifecycleAuthority lifecycleAuthority(HttpExchange exchange) { + String operation = exchange.getRequestHeaders().getFirst(RuntimeLifecycleAuthority.OPERATION_HEADER); + String generation = exchange.getRequestHeaders().getFirst(RuntimeLifecycleAuthority.GENERATION_HEADER); + if (operation == null && generation == null) { + return null; + } + try { + return new RuntimeLifecycleAuthority(operation, Long.parseLong(generation)); + } catch (RuntimeException error) { + throw new RuntimeBrokerException(400, "runtime_lifecycle_authority_invalid", "Invalid lifecycle authority", false); + } + } + private void acquire(HttpExchange exchange) throws IOException { Map body = requestBody(exchange, "acquire request"); requireProtocol(body); @@ -155,8 +177,15 @@ private void acquire(HttpExchange exchange) throws IOException { "runtimeSessionId", "acquire request"); String turnKind = JsonCodec.requiredString(body, "turnKind", "acquire request"); - complete(exchange, service.acquire(harnessSessionId, runtimeSessionId, - turnKind), record -> { + CompletionStage acquisition; + if (body.containsKey("recoveryBindingId")) { + acquisition = service.acquireRecovery(harnessSessionId, runtimeSessionId, + JsonCodec.requiredString(body, "recoveryBindingId", "acquire request"), + Long.parseLong(JsonCodec.requiredString(body, "recoveryGeneration", "acquire request"))); + } else { + acquisition = service.acquire(harnessSessionId, runtimeSessionId, turnKind, lifecycleAuthority(exchange)); + } + complete(exchange, acquisition, record -> { Map response = new LinkedHashMap<>(envelope(harnessSessionId, runtimeSessionId, "acquired", true)); RuntimeScope scope = record.getSession().getScope(); @@ -202,7 +231,7 @@ private void toolSession(HttpExchange exchange, String suffix) Map operation = requiredObject(body, "operation", "control request"); complete(exchange, service.control(harnessSessionId, - runtimeSessionId, operation), result -> envelope( + runtimeSessionId, operation, lifecycleAuthority(exchange)), result -> envelope( harnessSessionId, runtimeSessionId, "result", result)); return; diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerService.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerService.java index 286af2a206f..96ffa41bc42 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerService.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerService.java @@ -243,7 +243,9 @@ private CompletionStage drainBindings(String tenantId, String harnessSessi private CompletionStage drainBinding(RuntimeBindingRecord saved) { if (saved.getState() == RuntimeBindingRecord.State.RELEASED) { - return CompletableFuture.completedFuture(null); + return saved.getDrainReceipt() != null || saved.hasStoppedWriters() + ? CompletableFuture.completedFuture(null) + : failed(conflict("workspace_close_identity_unverified", "Released binding has no original stop proof")); } if (!saved.getRequest().isManagedContext() || !provisioner.supportsDrainedStop() || saved.getState() == RuntimeBindingRecord.State.OPERATOR_RECOVERY @@ -365,11 +367,15 @@ private CompletionStage releaseSavedSession(RuntimeBindingRecord binding, public CompletionStage warm( String harnessSessionId) { + return warm(harnessSessionId, null); + } + + public CompletionStage warm(String harnessSessionId, RuntimeLifecycleAuthority authority) { requireOpen(); String harnessId = BrokerValues.requirePathSafe( BrokerValues.requireId(harnessSessionId, "harnessSessionId"), "harnessSessionId"); - return resolveScope(harnessId) + return resolveScope(harnessId, authority) .thenCompose(scope -> ensureBinding( provisionRequest(scope, harnessId))) .thenApply(BindingContext::record); @@ -378,6 +384,11 @@ public CompletionStage warm( public CompletionStage acquire( String harnessSessionId, String runtimeSessionId, String turnKind) { + return acquire(harnessSessionId, runtimeSessionId, turnKind, null); + } + + public CompletionStage acquire(String harnessSessionId, String runtimeSessionId, + String turnKind, RuntimeLifecycleAuthority authority) { requireOpen(); String harnessId = BrokerValues.requirePathSafe( BrokerValues.requireId(harnessSessionId, "harnessSessionId"), @@ -390,6 +401,7 @@ public CompletionStage acquire( return current.thenApply(context -> { context.lock(); try { + bindingRepository.requireHarnessAdmission(context.session().getScope(), harnessId, authority); requireSameSession(context.session(), new RuntimeSession(harnessId, runtimeId, turnKind, context.session().getScope())); return requireReadySessionRecord(context); @@ -398,15 +410,66 @@ public CompletionStage acquire( } }); } - return resolveScope(harnessId).thenCompose(scope -> { + return resolveScope(harnessId, authority).thenCompose(scope -> { RuntimeSession session = new RuntimeSession(harnessId, runtimeId, turnKind, scope); return acquireSession(session); }); } + public CompletionStage acquireRecovery(String harnessSessionId, String runtimeSessionId, + String bindingId, long generation) { + requireOpen(); + RuntimeBindingRecord original = bindingRepository.findById(bindingId); + if (original == null || original.getGeneration() != generation + || !harnessSessionId.equals(original.getRequest().getIsolationKey()) + || !"session".equals(original.getRequest().getScope().getIsolationClass()) + || original.getState() != RuntimeBindingRecord.State.READY) { + return failed(conflict("workspace_close_identity_unverified", "Original Hook Runtime is unavailable")); + } + RuntimeSessionRecord saved = sessionRepository.findById(original.getRequest().getScope(), runtimeSessionId); + if (saved == null || !harnessSessionId.equals(saved.getSession().getHarnessSessionId()) + || !bindingId.equals(saved.getBindingId()) || saved.getRuntimeGeneration() != generation + || saved.getState() != RuntimeSessionRecord.State.READY) { + return failed(conflict("workspace_close_identity_unverified", "Original Hook Session is unavailable")); + } + CompletionStage recovered = matchingLiveBinding(original) == null + ? recoverBinding(bindingId, generation, true) : CompletableFuture.completedFuture(original); + return recovered.thenApply(record -> { + if (record.getState() != RuntimeBindingRecord.State.READY || !record.sameIdentity(original)) { + throw conflict("workspace_close_identity_unverified", "Original Hook Runtime cannot be recovered"); + } + BindingContext live = requireLiveBinding(record); + var context = CompletableFuture.completedFuture(new SessionContext(saved.getSession(), record, live.lease())); + var existing = sessions.putIfAbsent(runtimeSessionId, context); + if (existing != null) { + SessionContext current = existing.join(); + requireSameSession(current.session(), saved.getSession()); + if (!current.binding().sameIdentity(original)) { + throw conflict("workspace_close_identity_unverified", "Hook Runtime identity changed"); + } + } + return saved; + }); + } + + public CompletionStage authorizeLifecycle(String harnessSessionId, RuntimeLifecycleAuthority authority) { + if (authority == null) { + return failed(conflict("runtime_lifecycle_authority_required", "Lifecycle authority is required")); + } + return resolveScope(harnessSessionId, authority).thenApply(scope -> { + bindingRepository.requireHarnessAdmission(scope, harnessSessionId, authority); + return scope; + }); + } + public CompletionStage control(String harnessSessionId, String runtimeSessionId, Map operation) { + return control(harnessSessionId, runtimeSessionId, operation, null); + } + + public CompletionStage control(String harnessSessionId, + String runtimeSessionId, Map operation, RuntimeLifecycleAuthority authority) { requireOpen(); Map immutable = immutableMap(operation, "operation"); @@ -421,6 +484,17 @@ public CompletionStage control(String harnessSessionId, if (ManagedHookProtocol.isOperation(immutable)) { ManagedHookProtocol.validateSession(context.session(), immutable); } + boolean recovery = ManagedMcpProtocol.isRecovery(immutable) || ManagedHookProtocol.isRecovery(immutable); + if (!recovery) { + if (authority != null && !ManagedHookProtocol.isOperation(immutable)) { + throw conflict("runtime_lifecycle_operation_invalid", "Only Hook control may use lifecycle authority"); + } + if (ManagedHookProtocol.isOperation(immutable)) { + bindingRepository.requireHookAdmission(context.session().getScope(), harnessSessionId, authority); + } else { + bindingRepository.requireHarnessAdmission(context.session().getScope(), harnessSessionId, authority); + } + } context.lock(); try { requireReadySessionRecord(context); @@ -433,7 +507,9 @@ public CompletionStage control(String harnessSessionId, requireUsableLease(context); result = mapFailure(safeStage(() -> transport.control(context.lease(), - context.session(), immutable)), + authority == null && context.session().getScope().getLifecycleAuthority() == null ? context.session() + : new RuntimeSession(context.session().getHarnessSessionId(), context.session().getRuntimeSessionId(), + context.session().getTurnKind(), context.session().getScope().withLifecycleAuthority(authority)), immutable)), "runtime_control_failed", "Runtime control operation failed"); } catch (RuntimeException | Error failure) { @@ -1607,6 +1683,8 @@ private CompletionStage acquireSession( return selected.thenApply(context -> { context.lock(); try { + bindingRepository.requireHarnessAdmission(session.getScope(), session.getHarnessSessionId(), + session.getScope().getLifecycleAuthority()); requireSameSession(context.session(), session); return requireReadySessionRecord(context); } finally { @@ -1685,6 +1763,12 @@ private CompletionStage acquireNewSession( private CompletionStage ensureBinding( RuntimeProvisionRequest request) { RuntimeBindingRecord record = bindingRepository.findOrCreate(request); + if (request.getScope().getLifecycleAuthority() != null + && (record.getState() == RuntimeBindingRecord.State.LOST + || record.getState() == RuntimeBindingRecord.State.DRAINING + || record.getState() == RuntimeBindingRecord.State.RELEASED)) { + return failed(conflict("workspace_close_identity_unverified", "Original lifecycle Runtime is unavailable")); + } if (record.getState() == RuntimeBindingRecord.State.READY) { CompletableFuture finishing = bindingOperations.get(record.getBindingId()); @@ -1760,7 +1844,7 @@ private CompletionStage ensureBinding( if (existing != null) { return existing; } - safeStage(() -> provisionBinding(record)).whenComplete( + safeStage(() -> provisionBinding(record, request.getScope().getLifecycleAuthority())).whenComplete( (context, error) -> { bindingOperations.remove(record.getBindingId(), created); if (error == null) { @@ -1773,14 +1857,15 @@ private CompletionStage ensureBinding( } private CompletionStage provisionBinding( - RuntimeBindingRecord record) { - RuntimeBindingRecord claimed = bindingRepository.claimOperation( + RuntimeBindingRecord record, RuntimeLifecycleAuthority authority) { + RuntimeBindingRecord savedClaim = bindingRepository.claimOperation( record.getBindingId(), brokerOwnerId, operationLeaseDuration); - if (claimed == null) { + if (savedClaim == null) { return failed(unavailable("runtime_provisioning_in_progress", "another Broker owns Runtime provisioning")); } + RuntimeBindingRecord claimed = savedClaim.withLifecycleAuthority(authority); if (claimed.getState() != RuntimeBindingRecord.State.PROVISIONING) { if (claimed.getState() == RuntimeBindingRecord.State.READY @@ -2598,6 +2683,10 @@ private CompletionStage renewingStepOrNull( /** Trusted maintenance of the saved generation; never resolves current authorization or provisions a replacement. */ public CompletionStage recoverBinding(String bindingId, long expectedGeneration) { + return recoverBinding(bindingId, expectedGeneration, false); + } + + private CompletionStage recoverBinding(String bindingId, long expectedGeneration, boolean lookupOnly) { requireOpen(); RuntimeBindingRecord record = bindingRepository.findById(bindingId); if (record == null || record.getGeneration() != expectedGeneration @@ -2606,9 +2695,9 @@ public CompletionStage recoverBinding(String bindingId, lo } // Close owns retirement after journal settlement. Observing its retired // registration as journal loss would erase the clean-stop boundary. - if (record.getState() != RuntimeBindingRecord.State.LOST + if (!lookupOnly && record.getState() != RuntimeBindingRecord.State.LOST && "session".equals(record.getRequest().getScope().getIsolationClass()) - && bindingRepository.isHarnessDraining(record.getRequest().getScope().getTenantId(), + && bindingRepository.isHarnessAdmissionClosed(record.getRequest().getScope().getTenantId(), record.getRequest().getIsolationKey())) { return CompletableFuture.completedFuture(record); } @@ -2635,13 +2724,16 @@ public CompletionStage recoverBinding(String bindingId, lo if (claimed == null || claimed.getGeneration() != expectedGeneration) { return failed(unavailable("runtime_reconcile_in_progress", "Another Broker owns Runtime recovery")); } - if (claimed.getState() != RuntimeBindingRecord.State.LOST + if (!lookupOnly && claimed.getState() != RuntimeBindingRecord.State.LOST && "session".equals(claimed.getRequest().getScope().getIsolationClass()) - && bindingRepository.isHarnessDraining(claimed.getRequest().getScope().getTenantId(), + && bindingRepository.isHarnessAdmissionClosed(claimed.getRequest().getScope().getTenantId(), claimed.getRequest().getIsolationKey())) { return CompletableFuture.completedFuture(claimed); } if (claimed.getState() == RuntimeBindingRecord.State.LOST) { + if (lookupOnly) { + return failed(conflict("workspace_close_identity_unverified", "Original Hook Runtime is lost")); + } return cleanupLost(claimed); } if (!canReconcile(claimed) && claimed.getState() != RuntimeBindingRecord.State.DRAINING) { @@ -2654,6 +2746,9 @@ public CompletionStage recoverBinding(String bindingId, lo if (observed == null) { return CompletableFuture.completedFuture(current); } + if (lookupOnly && observed.getOutcome() != RuntimeObservation.Outcome.READY) { + return failed(conflict("workspace_close_identity_unverified", "Original Hook Runtime is unverified")); + } if (observed.getOutcome() == RuntimeObservation.Outcome.NOT_FOUND && observed.getLossEvidence() != null) { RuntimeBindingRecord lost = bindingRepository.compareAndSet(current, @@ -2887,6 +2982,7 @@ private void beginDispatch(SessionContext context, private void beginDispatch(SessionContext context, ToolExecutionRecord prepared, Map payload, RuntimePublicationGrant grant) { + bindingRepository.requireHarnessAdmission(context.session().getScope(), context.session().getHarnessSessionId(), null); if (payload == null && ("deferred".equals(prepared.getReference().get("dispatchMode")) || "deferred_v3".equals(prepared.getReference().get("dispatchMode"))) && !prepared.isCancelRequested()) { @@ -3629,10 +3725,14 @@ public void close() { provisioner.close(); } + private CompletionStage resolveScope(String harnessSessionId) { + return resolveScope(harnessSessionId, null); + } + private CompletionStage resolveScope( - String harnessSessionId) { + String harnessSessionId, RuntimeLifecycleAuthority authority) { return mapFailure(safeStage( - () -> sessionResolver.resolve(harnessSessionId)), + () -> sessionResolver.resolve(harnessSessionId, authority)), "runtime_scope_resolution_failed", "Runtime scope resolution failed").thenApply(scope -> { if (scope == null) { diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeLifecycleAuthority.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeLifecycleAuthority.java new file mode 100644 index 00000000000..3819505580d --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeLifecycleAuthority.java @@ -0,0 +1,14 @@ +package com.alibaba.qwen.code.runtimebroker; + +/** Request-scoped authority; never part of persisted Runtime identity. */ +public record RuntimeLifecycleAuthority(String operationId, long claimGeneration) { + public static final String OPERATION_HEADER = "X-Qwen-Lifecycle-Operation-Id"; + public static final String GENERATION_HEADER = "X-Qwen-Lifecycle-Claim-Generation"; + + public RuntimeLifecycleAuthority { + BrokerValues.requirePathSafe(BrokerValues.requireId(operationId, "operationId"), "operationId"); + if (claimGeneration < 1) { + throw new IllegalArgumentException("Lifecycle claim generation must be positive"); + } + } +} diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeScope.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeScope.java index c1f96ada528..c101fab6780 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeScope.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeScope.java @@ -10,10 +10,26 @@ public final class RuntimeScope { private final String canonicalCwd; private final String capabilityDigest; private final String isolationClass; + private final RuntimeLifecycleAuthority lifecycleAuthority; + + public RuntimeLifecycleAuthority getLifecycleAuthority() { + return lifecycleAuthority; + } + + public RuntimeScope withLifecycleAuthority(RuntimeLifecycleAuthority authority) { + return new RuntimeScope(tenantId, workspaceId, workspaceGeneration, + canonicalCwd, capabilityDigest, isolationClass, authority); + } public RuntimeScope(String tenantId, String workspaceId, String workspaceGeneration, String canonicalCwd, String capabilityDigest, String isolationClass) { + this(tenantId, workspaceId, workspaceGeneration, canonicalCwd, capabilityDigest, isolationClass, null); + } + + private RuntimeScope(String tenantId, String workspaceId, String workspaceGeneration, + String canonicalCwd, String capabilityDigest, String isolationClass, RuntimeLifecycleAuthority authority) { + this.lifecycleAuthority = authority; this.tenantId = BrokerValues.requireId(tenantId, "tenantId"); this.workspaceId = BrokerValues.requireId(workspaceId, "workspaceId"); this.workspaceGeneration = BrokerValues.requireId( diff --git a/packages/sdk-java/runtime-broker/src/main/resources/com/alibaba/qwen/code/runtimebroker/schema.sql b/packages/sdk-java/runtime-broker/src/main/resources/com/alibaba/qwen/code/runtimebroker/schema.sql index a7337761ada..54cfb2d2eb1 100644 --- a/packages/sdk-java/runtime-broker/src/main/resources/com/alibaba/qwen/code/runtimebroker/schema.sql +++ b/packages/sdk-java/runtime-broker/src/main/resources/com/alibaba/qwen/code/runtimebroker/schema.sql @@ -144,5 +144,9 @@ CREATE TABLE IF NOT EXISTS qwen_runtime_harness_drain ( harness_key VARCHAR(64) NOT NULL, tenant_id VARCHAR(512) NOT NULL, harness_session_id VARCHAR(512) NOT NULL, + phase VARCHAR(32) NOT NULL DEFAULT 'DRAINING', + operation_id VARCHAR(128), + claim_generation BIGINT NOT NULL DEFAULT 0, + claim_lease_until BIGINT, PRIMARY KEY (tenant_key, harness_key) ); diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerServiceTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerServiceTest.java index bdc105e032e..a860dfece67 100644 --- a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerServiceTest.java +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerServiceTest.java @@ -77,6 +77,28 @@ void sessionIsolationProvisionsOneBindingPerHarnessSession() { } } + @Test + void hookRecoveryUsesTheOriginalBindingAndNeverAcquiresAReplacement() { + try (Fixture fixture = new Fixture(SESSION_SCOPE)) { + RuntimeSessionRecord original = join(fixture.service.acquire("harness-a", "runtime-a", "bootstrap")); + int acquires = fixture.transport.acquireCalls.get(); + int provisions = fixture.provisioner.calls.get(); + assertSame(original, join(fixture.service.acquireRecovery("harness-a", "runtime-a", + original.getBindingId(), original.getRuntimeGeneration()))); + for (CompletionStage request : List.of( + fixture.service.acquireRecovery("harness-b", "runtime-a", original.getBindingId(), original.getRuntimeGeneration()), + fixture.service.acquireRecovery("harness-a", "runtime-a", original.getBindingId(), original.getRuntimeGeneration() + 1), + fixture.service.acquireRecovery("harness-a", "missing", original.getBindingId(), original.getRuntimeGeneration()))) { + assertEquals("workspace_close_identity_unverified", failure(request).getCode()); + } + join(fixture.service.release("harness-a", "runtime-a")); + assertEquals("workspace_close_identity_unverified", failure(fixture.service.acquireRecovery("harness-a", "runtime-a", + original.getBindingId(), original.getRuntimeGeneration())).getCode()); + assertEquals(acquires, fixture.transport.acquireCalls.get()); + assertEquals(provisions, fixture.provisioner.calls.get()); + } + } + @Test void concurrentAcquireOfOneSessionCallsRuntimeOnce() { try (Fixture fixture = new Fixture(WORKSPACE_SCOPE)) { @@ -1207,6 +1229,24 @@ void refusesIllFormedMcpStringsBeforeForwarding() { } } + @Test + void legacyDrainAllowsOnlyOriginalHookControlAndNoNewOrdinaryAdmission() { + try (Fixture fixture = new Fixture(SESSION_SCOPE)) { + RuntimeSessionRecord session = join(fixture.service.acquire("harness", "runtime", "bootstrap")); + fixture.bindingRepository.requestHarnessDrain("tenant", "harness"); + Map hook = Map.of("kind", "hook-execute", "operationId", "legacy-end", + "sessionKey", Map.of("tenantId", "tenant", "workspaceId", "workspace", "sessionId", "harness")); + assertEquals("ok", join(fixture.service.control("harness", "runtime", hook))); + assertEquals(session.getSession(), fixture.transport.lastSession); + Map ordinary = Map.of("kind", "mcp-configure", "operationId", "ordinary", + "sessionKey", Map.of("tenantId", "tenant", "workspaceId", "workspace", "sessionId", "harness")); + assertEquals("runtime_admission_closed", failure(fixture.service.control("harness", "runtime", ordinary)).getCode()); + assertEquals("runtime_admission_closed", failure(fixture.service.acquire("harness", "new-runtime", "bootstrap")).getCode()); + assertEquals(1, fixture.provisioner.calls.get()); + assertEquals(1, fixture.transport.acquireCalls.get()); + } + } + @Test void observesTheOriginalOwnerAfterNewAdmissionIsRevoked() { try (Fixture fixture = new Fixture(SESSION_SCOPE)) { From 61978cb47773eb520d8af74061fa3a2c9cb8823f Mon Sep 17 00:00:00 2001 From: jinye Date: Sun, 4 Oct 2026 09:04:02 +0800 Subject: [PATCH 02/17] fix(managed-agent): align lifecycle CI fixtures --- .../helpers/hosted-session-store.ts | 8 +++++ .../WorkspaceSessionCloseTest.java | 20 +++++++++-- .../WorkspaceSessionRetentionTest.java | 33 ++++++++++++++----- .../RuntimeBrokerHttpServer.java | 20 +++++------ 4 files changed, 60 insertions(+), 21 deletions(-) diff --git a/integration-tests/helpers/hosted-session-store.ts b/integration-tests/helpers/hosted-session-store.ts index 8510f756d6c..27496df16b5 100644 --- a/integration-tests/helpers/hosted-session-store.ts +++ b/integration-tests/helpers/hosted-session-store.ts @@ -40,6 +40,7 @@ export async function startHostedSessionStore(sessionId: string) { const pending = new Set>(); let handle: ManagedSessionJournalHandle | undefined; let writerToken: string | undefined; + let writerId: string | undefined; let writerGeneration = 0; let seals = 0; const scan = () => @@ -77,6 +78,7 @@ export async function startHostedSessionStore(sessionId: string) { return; } writerToken = String(req.headers['x-qwen-managed-writer-token']); + writerId = String(body.writerId); handle = await journal.open({ sessionKey }); writerGeneration++; } else { @@ -98,6 +100,12 @@ export async function startHostedSessionStore(sessionId: string) { }; if (route === '/writers:acquire' || route === '/writers:renew') { json({ ...head, leaseUntil: Date.now() + Number(body.leaseMillis) }); + } else if (route === '/execution:authorize') { + assert.equal(req.method, 'POST'); + assert(handle); + assert.equal(body.writerId, writerId); + assert.equal(body.writerGeneration, writerGeneration); + json({ authorized: true }); } else if (route === '/restore') { json({ ...head, diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionCloseTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionCloseTest.java index 9a34d5e0c5a..d26cb163e95 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionCloseTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionCloseTest.java @@ -5,13 +5,19 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import com.alibaba.qwen.code.managedagent.api.ApiException; import com.alibaba.qwen.code.managedagent.api.AuthenticatedTenantActor; import com.alibaba.qwen.code.managedagent.api.TenantContextFilter; +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector; +import com.alibaba.qwen.code.managedagent.harness.UnavailableHarnessConnector; import com.alibaba.qwen.code.managedagent.service.RuntimeWarmer; import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore; import com.alibaba.qwen.code.managedagent.store.ManagedSessionStore; import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.AcquireWriterRequest; import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.SealWriterRequest; +import com.alibaba.qwen.code.runtimebroker.AesGcmSecretProtector; +import com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository; +import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRepository; import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; import com.fasterxml.jackson.databind.ObjectMapper; @@ -63,7 +69,7 @@ void bothSurfacesReplayOneCloseAndWaitForDurableCleanupAfterRevocation() throws await().untilAsserted(() -> assertThat(runtime.fenced).containsKey(session)); assertThat(store.requireSession(tenant, session).status()).isEqualTo("CLOSING"); assertThatThrownBy(() -> journal.acquireWriter(tenant, session, "a".repeat(43), - new AcquireWriterRequest("ws", "writer", 10_000L))).hasMessageContaining("closing"); + new AcquireWriterRequest("ws", "writer", 10_000L))).isInstanceOfSatisfying(ApiException.class, error -> assertThat(error.getCode()).isEqualTo("managed_session_lifecycle_active")); mvc.perform(post("/api/agent/web-shell/v1/sessions/close").header(TenantContextFilter.HEADER, tenant) .principal(actor(tenant, "owner")).contentType(MediaType.APPLICATION_JSON) .content("{\"sessionId\":\"" + session + "\",\"idempotencyKey\":\"close\"}")) @@ -197,6 +203,15 @@ private AuthenticatedTenantActor actor(String tenant, String id) { @TestConfiguration static class Configuration { + @Bean RuntimeBindingRepository lifecycleBindings(javax.sql.DataSource source) { + return new JdbcRuntimeBindingRepository(source, new AesGcmSecretProtector("test", new byte[32])); + } + @Bean @Primary HarnessConnector uninitializedHarness() { + return new UnavailableHarnessConnector() { + public boolean supportsLifecycle() { return true; } + public void detachLifecycle(com.alibaba.qwen.code.managedagent.store.StoreModels.OperationRecord operation) {} + }; + } @Bean @Primary CloseRuntime closeRuntime() { return new CloseRuntime(); } @Bean @Primary ManagedAgentStore closeStore(JdbcTemplate jdbc, ObjectMapper mapper, com.alibaba.qwen.code.managedagent.store.ManagedWorkspaceRegistry registry) { @@ -213,8 +228,9 @@ static class CloseRuntime implements RuntimeWarmer { public boolean supportsWorkspaceClose() { return true; } public CompletionStage warm(String id) { return CompletableFuture.completedFuture(null); } public CompletionStage drain(String id) { return CompletableFuture.completedFuture(null); } - public void requestWorkspaceClose(String tenant, String id) { fenced.put(id, tenant); } + public void requestWorkspaceClose(String tenant, String id) { throw new AssertionError("Unexpected legacy close"); } public CompletionStage closeWorkspace(String tenant, String id) { + fenced.put(id, tenant); return results.computeIfAbsent(id, ignored -> CompletableFuture.completedFuture(null)); } } diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionRetentionTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionRetentionTest.java index 3bb892148d9..0a738a02232 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionRetentionTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionRetentionTest.java @@ -6,9 +6,12 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import com.alibaba.qwen.code.managedagent.api.ApiException; import com.alibaba.qwen.code.managedagent.api.AuthenticatedTenantActor; import com.alibaba.qwen.code.managedagent.api.TenantContextFilter; import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector; +import com.alibaba.qwen.code.managedagent.harness.UnavailableHarnessConnector; import com.alibaba.qwen.code.managedagent.service.RuntimeWarmer; import com.alibaba.qwen.code.managedagent.service.SessionLifecycleCoordinator; import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore; @@ -17,6 +20,9 @@ import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.SealWriterRequest; import com.alibaba.qwen.code.managedagent.store.ManagedWorkspaceRegistry; import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationKind; +import com.alibaba.qwen.code.runtimebroker.AesGcmSecretProtector; +import com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository; +import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRepository; import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -88,10 +94,10 @@ void archivesAndUnarchivesAcrossSurfacesWithoutReopeningOrRepeatingCleanup() thr request(post(PUBLIC + session + "/unarchive"), tenant, "owner", "unarchive") .andExpect(status().isOk()).andExpect(jsonPath("$.status").value("archived")); assertThatThrownBy(() -> journal.acquireWriter(tenant, session, "b".repeat(43), - new AcquireWriterRequest("ws", "late", 60_000L))).hasMessageContaining("closing or closed"); + new AcquireWriterRequest("ws", "late", 60_000L))).isInstanceOfSatisfying(ApiException.class, error -> assertThat(error.getCode()).isEqualTo("managed_session_lifecycle_active")); assertThat(count(tenant, session, "session.archived")).isEqualTo(2); assertThat(count(tenant, session, "session.unarchived")).isEqualTo(1); - assertThat(runtime.calls.get(session)).isEqualTo(2); + assertThat(runtime.calls.get(session)).isEqualTo(1); } @ParameterizedTest @@ -119,7 +125,7 @@ void deletesClosedOrArchivedWithoutRuntimeAndRetiresTheJournal(boolean archived) assertThat(jdbc.queryForObject("SELECT operation_id FROM qwen_output_session_retirement WHERE tenant_id = ?" + " AND session_id = ?", String.class, tenant, session)).isEqualTo(id); assertThat(count(tenant, session, "session.deleted")).isEqualTo(1); - assertThat(runtime.calls.get(session)).isEqualTo(2); + assertThat(runtime.calls.get(session)).isEqualTo(1); } @ParameterizedTest @@ -155,7 +161,7 @@ void recoversDeletionBlockedByAnOlderCoordinatorWithoutRuntime(boolean archived) assertThat(jdbc.queryForObject("SELECT operation_id FROM qwen_output_session_retirement WHERE tenant_id = ?" + " AND session_id = ?", String.class, tenant, session)).isEqualTo(id); assertThat(count(tenant, session, "session.deleted")).isEqualTo(1); - assertThat(runtime.calls.get(session)).isEqualTo(2); + assertThat(runtime.calls.get(session)).isEqualTo(1); } @Test @@ -204,10 +210,10 @@ void permissionsAreRecheckedForMutationsReplaysAndTombstoneOperations() throws E } @Test - void sourceStatesAndMissingCloseProofAreRefused() throws Exception { + void archiveAndUnarchiveSourceStatesAndMissingCloseProofAreRefused() throws Exception { String tenant = tenant(); String session = create(tenant); - for (String operation : new String[] {"archive", "unarchive", "delete"}) { + for (String operation : new String[] {"archive", "unarchive"}) { web(operation, tenant, session, "owner", "key").andExpect(status().isConflict()) .andExpect(jsonPath("$.error.code").value("session_state_conflict")); } @@ -248,7 +254,7 @@ void metadataSurvivesRuntimeSupportRemoval() throws Exception { String id = json(web("delete", tenant, session, "owner", "delete").andExpect(status().isAccepted())) .path("operationId").asText(); await().untilAsserted(() -> assertThat(store.findOperation(tenant, session, id).orElseThrow().state()).isEqualTo("COMPLETED")); - assertThat(runtime.calls.get(session)).isEqualTo(2); + assertThat(runtime.calls.get(session)).isEqualTo(1); } finally { runtime.supportRemoved = false; } @@ -351,6 +357,15 @@ private AuthenticatedTenantActor actor(String tenant, String id) { @TestConfiguration static class Configuration { + @Bean RuntimeBindingRepository lifecycleBindings(javax.sql.DataSource source) { + return new JdbcRuntimeBindingRepository(source, new AesGcmSecretProtector("test", new byte[32])); + } + @Bean @Primary HarnessConnector uninitializedHarness() { + return new UnavailableHarnessConnector() { + public boolean supportsLifecycle() { return true; } + public void detachLifecycle(com.alibaba.qwen.code.managedagent.store.StoreModels.OperationRecord operation) {} + }; + } @Bean @Primary OnceCloseRuntime retentionRuntime() { return new OnceCloseRuntime(); } @Bean @Primary ManagedAgentStore retentionStore(JdbcTemplate jdbc, ObjectMapper mapper, ManagedWorkspaceRegistry registry) { var properties = new ManagedAgentProperties(); @@ -368,10 +383,10 @@ public boolean supportsWorkspaceClose() { } public CompletionStage warm(String id) { throw new AssertionError("Unexpected warm"); } public CompletionStage drain(String id) { throw new AssertionError("Unexpected drain"); } - public void requestWorkspaceClose(String tenant, String id) { record(id); } + public void requestWorkspaceClose(String tenant, String id) { throw new AssertionError("Unexpected legacy close"); } public CompletionStage closeWorkspace(String tenant, String id) { record(id); return CompletableFuture.completedFuture(null); } private void record(String id) { - if (calls.merge(id, 1, Integer::sum) > 2) { throw new AssertionError("Cleanup repeated after close"); } + if (calls.merge(id, 1, Integer::sum) > 1) { throw new AssertionError("Cleanup repeated after close"); } } } } diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServer.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServer.java index b222a7c1716..782ef1d3880 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServer.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServer.java @@ -186,16 +186,16 @@ private void acquire(HttpExchange exchange) throws IOException { acquisition = service.acquire(harnessSessionId, runtimeSessionId, turnKind, lifecycleAuthority(exchange)); } complete(exchange, acquisition, record -> { - Map response = new LinkedHashMap<>(envelope(harnessSessionId, - runtimeSessionId, "acquired", true)); - RuntimeScope scope = record.getSession().getScope(); - response.put("scope", Map.of("tenantId", scope.getTenantId(), - "workspaceId", scope.getWorkspaceId(), "workspaceGeneration", scope.getWorkspaceGeneration(), - "capabilityDigest", scope.getCapabilityDigest())); - response.put("runtime", Map.of("bindingId", record.getBindingId(), - "generation", Long.toString(record.getRuntimeGeneration()))); - return response; - }); + Map response = new LinkedHashMap<>(envelope(harnessSessionId, + runtimeSessionId, "acquired", true)); + RuntimeScope scope = record.getSession().getScope(); + response.put("scope", Map.of("tenantId", scope.getTenantId(), + "workspaceId", scope.getWorkspaceId(), "workspaceGeneration", scope.getWorkspaceGeneration(), + "capabilityDigest", scope.getCapabilityDigest())); + response.put("runtime", Map.of("bindingId", record.getBindingId(), + "generation", Long.toString(record.getRuntimeGeneration()))); + return response; + }); } private void toolSession(HttpExchange exchange, String suffix) From 7f3e9df0fad32745b6b8f7ad7e3f9547ecfd0508 Mon Sep 17 00:00:00 2001 From: jinye Date: Sun, 4 Oct 2026 10:03:05 +0800 Subject: [PATCH 03/17] fix(hosted): authorize detach after durable lifecycle recovery (#13354) --- .../workspace-session-active-delete-l3.md | 7 +++ ...orkspace-session-active-delete-l3.zh-CN.md | 5 ++ .../src/serve/hosted-harness-session.test.ts | 54 ++++++++++++++++++- .../cli/src/serve/hosted-harness-session.ts | 30 ++++++++--- .../serve/hosted-workspace-tool-turn.test.ts | 3 ++ .../managedagent/ManagedAgentMySqlIT.java | 14 +++-- .../managed/generated/managed-agent-api.ts | 8 +-- 7 files changed, 102 insertions(+), 19 deletions(-) diff --git a/docs/design/workspace-session-active-delete-l3.md b/docs/design/workspace-session-active-delete-l3.md index 0bcd647a0b6..9134e5be61e 100644 --- a/docs/design/workspace-session-active-delete-l3.md +++ b/docs/design/workspace-session-active-delete-l3.md @@ -83,6 +83,13 @@ reliable-close drain/stop protocol. A successor skips Hooks when the effects receipt is saved, otherwise reconstructs progress from the same H2 occurrences. Harness 404, lease expiry and worker disappearance cannot establish completion. +Receipt recovery may skip the Harness lifecycle request entirely. Detach must +therefore validate its operation authority against Session Store even when the +live attachment has no local lifecycle state. Store authorization checks the +current claim, original writer, saved effects and DRAINING fence; a rejected +claim leaves the attachment and its prior authority intact. Ordinary detach +still requires ordinary execution authorization. + Final completion requires the live delivery claim, effects receipt, permanent fence, no live writer or unsettled execution, and verifiable original stop proofs. CLOSE commits CLOSED. DELETE commits O4 retirement, DELETED, operation diff --git a/docs/design/workspace-session-active-delete-l3.zh-CN.md b/docs/design/workspace-session-active-delete-l3.zh-CN.md index d1f1da3f123..9b9e70374eb 100644 --- a/docs/design/workspace-session-active-delete-l3.zh-CN.md +++ b/docs/design/workspace-session-active-delete-l3.zh-CN.md @@ -67,6 +67,11 @@ plan 和子执行派发前均预检权限,并在 journal 提交事务中重新 receipt 已保存时跳过 Hook,否则从相同 H2 occurrence 恢复进度。Harness 404、 租约过期或 worker 消失均不能证明完成。 +receipt 恢复可能完全跳过 Harness lifecycle 请求。因此,即使存活 attachment +没有本地 lifecycle 状态,detach 也必须向 Session Store 核验 operation 权限。 +Store 授权检查当前 claim、原 writer、已保存的 effects 与 DRAINING 围栏; +claim 被拒绝时保留 attachment 及其原权限。普通 detach 仍须通过普通执行授权。 + 最终完成要求有效 delivery claim、effects receipt、永久围栏、无有效 writer 或 未结算执行,以及可核验的原停机证明。CLOSE 提交 CLOSED;DELETE 原子提交 O4 退役、DELETED、operation 完成与终止事件。共享文件及其他会话的 holder 保留。 diff --git a/packages/cli/src/serve/hosted-harness-session.test.ts b/packages/cli/src/serve/hosted-harness-session.test.ts index 54d2c3fca3b..69585e0782f 100644 --- a/packages/cli/src/serve/hosted-harness-session.test.ts +++ b/packages/cli/src/serve/hosted-harness-session.test.ts @@ -83,6 +83,7 @@ const state = vi.hoisted(() => ({ toolResults: null as DurableToolResultResourceStore | null, publicationRequest: vi.fn(), storeOptions: [] as Array>, + setLifecycleAuthority: vi.fn(), authorizeLifecycle: vi.fn(async () => undefined), authorizeOrdinary: vi.fn(async (_kind?: 'legacy-close') => undefined), model: vi.fn( @@ -130,7 +131,7 @@ vi.mock( resourceStore, toolResultResources: state.toolResults ?? resourceStore, assertWritable: state.assertWritable, - setLifecycleAuthority: () => undefined, + setLifecycleAuthority: state.setLifecycleAuthority, authorizeOrdinary: state.authorizeOrdinary, authorizeLifecycle: state.authorizeLifecycle, publication: { @@ -384,6 +385,7 @@ describe('Hosted Harness no-tool session', () => { state.assertWritable.mockResolvedValue(undefined); state.authorizeLifecycle.mockReset(); state.authorizeLifecycle.mockResolvedValue(undefined); + state.setLifecycleAuthority.mockReset(); state.authorizeOrdinary.mockReset(); state.authorizeOrdinary.mockResolvedValue(undefined); state.model.mockReset(); @@ -447,6 +449,56 @@ describe('Hosted Harness no-tool session', () => { }, ); + it('authorizes detach after the coordinator recovers effects without a Harness lifecycle request', async () => { + const { server, authorize, requests } = await hookApp(); + state.authorizeOrdinary.mockRejectedValue(new Error('DRAINING')); + const authority = { operationId: 'recovered-effects', claimGeneration: 2 }; + await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .send({ authority }) + .expect(204); + expect(state.setLifecycleAuthority).toHaveBeenLastCalledWith(authority); + expect(state.authorizeLifecycle).toHaveBeenCalledExactlyOnceWith(); + expect(state.authorizeOrdinary).not.toHaveBeenCalled(); + expect(requests).toEqual([]); + expect(state.model).not.toHaveBeenCalled(); + await authorize(supertest(server).post(`/session/${SESSION_ID}/prompt`)) + .send({}) + .expect(404); + }); + + it('retains the attachment and restores authority when a recovered detach claim is rejected', async () => { + const { server, authorize, requests } = await hookApp(); + state.authorizeOrdinary.mockRejectedValue(new Error('DRAINING')); + state.authorizeLifecycle.mockRejectedValueOnce(new Error('stale claim')); + await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .send({ authority: { operationId: 'stale', claimGeneration: 1 } }) + .expect(503); + expect(state.setLifecycleAuthority).toHaveBeenLastCalledWith(undefined); + expect(requests).toEqual([]); + await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .send({}) + .expect(409); + await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .send({ authority: { operationId: 'valid', claimGeneration: 2 } }) + .expect(204); + expect(state.authorizeLifecycle).toHaveBeenCalledTimes(2); + expect(requests).toEqual([]); + }); + + it('rejects malformed detach authority without bypassing a persistent fence', async () => { + const { server, authorize, requests } = await hookApp(); + state.authorizeOrdinary.mockRejectedValue(new Error('DRAINING')); + await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .send({ authority: { operationId: 'malformed', claimGeneration: 0 } }) + .expect(400); + expect(state.authorizeLifecycle).not.toHaveBeenCalled(); + expect(state.setLifecycleAuthority).not.toHaveBeenCalled(); + await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .send({ authority: { operationId: 'valid', claimGeneration: 2 } }) + .expect(204); + expect(requests).toEqual([]); + }); + it('uses the legacy close admission for an attached protocol zero Session with Hooks', async () => { const { server, authorize, requests } = await hookApp(); state.authorizeOrdinary.mockImplementation(async (kind) => { diff --git a/packages/cli/src/serve/hosted-harness-session.ts b/packages/cli/src/serve/hosted-harness-session.ts index 3aae25bddbd..d9f6135a3d0 100644 --- a/packages/cli/src/serve/hosted-harness-session.ts +++ b/packages/cli/src/serve/hosted-harness-session.ts @@ -2137,7 +2137,7 @@ export function registerHostedHarnessSessionRoutes( if ( session && req.method !== 'GET' && - !['/lifecycle', '/heartbeat'].includes(req.path) && + !['/lifecycle', '/detach', '/heartbeat'].includes(req.path) && !session.lifecycle ) { try { @@ -3672,21 +3672,39 @@ export function registerHostedHarnessSessionRoutes( session.hooksBusy ) return error(res, 409, 'hosted_turn_active'); + let authority: ManagedSessionLifecycleAuthority | undefined; + try { + authority = lifecycleAuthority(object(req.body)?.['authority']); + } catch { + return error(res, 400, 'invalid_hosted_lifecycle_authority'); + } session.mcpBusy = true; session.mcpClosing = true; try { - if (session.lifecycle) { + if (session.lifecycle || authority) { if (req.method === 'DELETE') return error(res, 409, 'hosted_lifecycle_operation_active'); - const authority = lifecycleAuthority(object(req.body)?.['authority']); if ( !authority || - authority.operationId !== session.lifecycle.operationId + (session.lifecycle && + authority.operationId !== session.lifecycle.operationId) ) return error(res, 409, 'hosted_lifecycle_operation_conflict'); - session.lifecycle = authority; + const previousAuthority = session.lifecycle; session.stores!.setLifecycleAuthority(authority); - await session.stores!.authorizeLifecycle(); + try { + await session.stores!.authorizeLifecycle(); + } catch (cause) { + session.stores!.setLifecycleAuthority(previousAuthority); + throw cause; + } + session.lifecycle = authority; + } else if (req.method === 'POST') { + try { + await session.stores!.authorizeOrdinary(); + } catch { + return error(res, 409, 'hosted_lifecycle_operation_active'); + } } if (req.method === 'DELETE' && session.hooks) { session.hooksBusy = true; diff --git a/packages/cli/src/serve/hosted-workspace-tool-turn.test.ts b/packages/cli/src/serve/hosted-workspace-tool-turn.test.ts index 0bca8136546..27d294064b7 100644 --- a/packages/cli/src/serve/hosted-workspace-tool-turn.test.ts +++ b/packages/cli/src/serve/hosted-workspace-tool-turn.test.ts @@ -56,6 +56,7 @@ import * as stdio from '../utils/stdioHelpers.js'; const broker = vi.hoisted(() => ({ fileHistory: vi.fn(), + authorizeLifecycle: vi.fn(), warm: vi.fn(), acquire: vi.fn(), prepare: vi.fn(), @@ -73,6 +74,7 @@ vi.mock('./hosted-workspace-broker.js', async (importOriginal) => ({ HostedWorkspaceBroker: class { readonly runtimeSessionId = 'prompt'; fileHistory = broker.fileHistory; + authorizeLifecycle = broker.authorizeLifecycle; warm = broker.warm; acquire = broker.acquire; prepare = broker.prepare; @@ -141,6 +143,7 @@ beforeEach(async () => { vi.resetAllMocks(); expectWritesStopped = false; for (const method of [ + broker.authorizeLifecycle, broker.warm, broker.acquire, broker.cancel, diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentMySqlIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentMySqlIT.java index cfa8a1e8699..c95f9c32906 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentMySqlIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentMySqlIT.java @@ -755,17 +755,12 @@ void announcesNothingAfterDeletionStartsMidCommit() throws Exception { if ("appendLiveSessionEventIfAbsent".equals( method.getName())) { CompletableFuture.runAsync(() -> { - String operation = inTransaction(transactions, + deletion.set(inTransaction(transactions, () -> agents.beginOperation(tenant, session, OperationKind.DELETE, "sha256:" + "c".repeat(64), "delete", "digest-delete")) - .operation().operationId(); - deletion.set(inTransaction(transactions, - () -> agents.claimOperation(tenant, - session, operation, "worker", - Duration.ofMinutes(1))) - .orElseThrow()); + .operation()); }).join(); } try { @@ -790,7 +785,10 @@ void announcesNothingAfterDeletionStartsMidCommit() throws Exception { assertThat(jdbc.queryForObject("SELECT status FROM managed_agent_session" + " WHERE tenant_id = ? AND session_id = ?", String.class, tenant, session)).isEqualTo("DELETING"); - OperationRecord operation = deletion.get(); + OperationRecord operation = inTransaction(transactions, + () -> agents.claimOperation(tenant, session, + deletion.get().operationId(), "worker", + Duration.ofMinutes(1))).orElseThrow(); assertThatThrownBy(() -> inTransaction(transactions, () -> agents.completeOperation(tenant, session, operation.operationId(), "worker", diff --git a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts index 9c95b5d632a..4daf42a0670 100644 --- a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts +++ b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts @@ -76,7 +76,7 @@ export interface paths { }; get?: never; put?: never; - /** @description Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns and cancel its running Turns under the same opt-in, and may rename the Session, 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. */ + /** @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 and unarchive follow retention capabilities after reliable Workspace close; delete supports either L3 idle ACTIVE files Sessions or L2 CLOSED/ARCHIVED Sessions; 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; @@ -209,7 +209,7 @@ export interface paths { }; get?: never; put?: never; - /** @description Uses the same close admission, creator authorization and durable cleanup semantics as the public close route. sessionClose describes deployment support; it does not grant actor authorization. */ + /** @description Uses the same close admission, creator authorization and durable cleanup semantics as the public close route. sessionClose describes deployment support; it does not grant actor authorization. New L3 Workspace close operations run SessionEnd only, retain data, and finish after durable effects verification and original Runtime stop. SessionDelete is reserved for ACTIVE delete. Missing protocol support rejects new admission; previously admitted operations retain their saved protocol. Unknown outcomes remain recovery_blocked. */ post: operations["closeWebShellSession"]; delete?: never; options?: never; @@ -243,7 +243,7 @@ export interface paths { }; get?: never; put?: never; - /** @description Workspace-bound delete accepts only closed or archived Sessions with completed reliable-close evidence and the creator with current read access. Active deletion returns 409 session_state_conflict. Completion atomically retires private recovery/publication access with the tombstone, operation receipt and terminal event; it calls neither Harness nor Runtime and erases no shared Workspace files or backup bytes. Operations remain readable under current read grants; the same authorized actor/key can replay after deletion. A readable non-creator returns 403; missing close evidence returns 409 workspace_unavailable. */ + /** @description Workspace-bound ACTIVE deletion supports idle hosted-workspace-files/1 Sessions when L3 lifecycle protocol and reliable Runtime stop are available. Only the creator with current read access may admit it; accepted, running, cancelling or approval-waiting Turns return 409 turn_active. Admission persists a lifecycle-only execution fence and a versioned actor-scoped operation. SessionEnd settles before SessionDelete; committed Hook results are verified in Session Store and saved before permanent draining, attachment cleanup and original worker stop. Unknown outcomes or missing original stop evidence remain recovery_blocked and are never replayed on a replacement worker. CLOSED/ARCHIVED deletion retains L2: completed reliable-close evidence is required and no Harness or Hook is invoked. Completion atomically commits retirement, tombstone, receipt and terminal event, retaining shared Workspace files and neighboring holders. Authorized same-actor/key replays return the original operation, including after deletion; readable non-creators return 403. Missing lifecycle capability or close evidence returns 409 workspace_unavailable. */ post: operations["deleteWebShellSession"]; delete?: never; options?: never; @@ -519,7 +519,7 @@ export interface components { */ sessionUnarchive?: boolean; /** - * @description Optional support for this retention operation. For a Workspace-bound Session requires completed reliable-close evidence; authorization and source-state validation still apply. + * @description Optional support capability. ACTIVE Workspace files Sessions require L3 lifecycle protocol and reliable original Runtime stop; CLOSED/ARCHIVED Sessions use independent L2 close evidence. Current creator/read authorization and idle/source-state admission checks still apply. The aggregate session lifecycle capability is unchanged. * @default false */ sessionDelete?: boolean; From 1edeff346b32c2e62d514c0b756927e97d2ad0a9 Mon Sep 17 00:00:00 2001 From: jinye Date: Sun, 4 Oct 2026 11:11:30 +0800 Subject: [PATCH 04/17] fix(hosted): scope lifecycle fence locks and authorization errors --- .../workspace-session-active-delete-l3.md | 37 ++++++++ ...orkspace-session-active-delete-l3.zh-CN.md | 27 ++++++ .../src/serve/hosted-harness-session.test.ts | 92 ++++++++++++++++++- .../cli/src/serve/hosted-harness-session.ts | 19 +++- .../managedagent/store/ManagedAgentStore.java | 13 ++- .../store/ManagedSessionStore.java | 17 +++- .../store/WorkspaceLifecycleStore.java | 11 ++- .../store/WorkspaceLifecycleMySqlIT.java | 45 +++++++++ .../JdbcRuntimeBindingRepository.java | 4 + 9 files changed, 244 insertions(+), 21 deletions(-) diff --git a/docs/design/workspace-session-active-delete-l3.md b/docs/design/workspace-session-active-delete-l3.md index 9134e5be61e..1af8a602466 100644 --- a/docs/design/workspace-session-active-delete-l3.md +++ b/docs/design/workspace-session-active-delete-l3.md @@ -25,6 +25,19 @@ running, cancelling or approval-waiting Turn rejects admission with their L2 semantics. Once means reusing committed outcomes and never redispatching an attempt that might have begun, not guaranteed eventual completion. +### Why close followed by L2 delete is insufficient + +Reliable close settles SessionEnd and permanently stops the original Runtime. +L2 deletion of CLOSED/ARCHIVED Sessions intentionally runs no Hooks. Composing +those operations therefore cannot settle SessionDelete on the original Runtime +after End and before shutdown. Restoring a replacement worker to run Delete +would violate the no-replay boundary. L3 reuses reliable close's stop machinery +and L2's atomic retirement transaction, but settles both required events before +permanent draining. One delete operation remains DELETING throughout recovery; +it does not publish a separate successful CLOSE and later attempt an unrelated +L2 operation. The effects receipt lets successors skip committed Hook outcomes +without treating a missing Harness or an HTTP response as completion evidence. + ## 2. Protocol and evidence Add private `POST /session/:id/lifecycle`, carrying Session scope, operationId, @@ -57,6 +70,21 @@ and execution authority in the original Workspace scope. This authority still requires private authentication and current execution authorization. Use the existing lock hierarchy to prevent authorization/admission races. +The placement guard precedes retention, Session and journal locks. Checking only +the journal head or a missing fence row cannot exclude concurrent fence +insertion; writer mutations and ordinary execution authorization must share +admission's lock order. The pre-L3 Store already obtains the publication tenant +lock through its Session lock helper. L3 adds the placement guard and ordinary +authorization request, so its incremental contention and request cost require +measurement. Drain lookups and mutations use the existing hashed primary key, +with original identity checks, so their locking reads do not scan other tenants' +fences. The key encoding is shared with the Broker; no new index is required. +This protects persisted Session admission on every hosted +attachment, including one without local lifecycle state; it does not grant +every Session L3 delete support. A definite Store lifecycle-fence rejection +returns 409. Unexpected Store, transport or writer-authority failures return +503 and admit no execution. Protocol-zero close retains its scoped exception. + Settle earlier operations, excluding this operation's lifecycle occurrences from generic cancellation. Stable occurrence IDs derive from Session, operation and event. Reuse H2 catalog, plan, dispatch intent and results. SessionDelete starts @@ -102,6 +130,15 @@ writer, journal or Hook dispatch records. Save never-initialized no-Hook evidenc With a header, inspect the original definition/catalog instead. Either case still checks the complete Runtime binding set after the permanent fence. +Admission scans and JSON-parses the complete journal to prove there are no +accepted, unsettled Turns. Its cost grows with retained history while the +transaction holds its locks; bounded admission latency is not established. +The current product initializes the compaction watermark to zero and has no +production path that advances it. Existing cold recovery also rejects nonzero +watermarks; L3 rejects them with `workspace_lifecycle_journal_unverified`. +Future compaction requires durable idle-state evidence before L3 can +accept those Sessions; this PR does not implement compaction recovery. + ## 4. Compatibility and rollout Add a new migration; preserve V32. Historical admitted operations retain their diff --git a/docs/design/workspace-session-active-delete-l3.zh-CN.md b/docs/design/workspace-session-active-delete-l3.zh-CN.md index 9b9e70374eb..370837734cf 100644 --- a/docs/design/workspace-session-active-delete-l3.zh-CN.md +++ b/docs/design/workspace-session-active-delete-l3.zh-CN.md @@ -21,6 +21,16 @@ mutation 仍要求当前可读的创建者。已接纳、运行中、取消中 返回 `409 turn_active`。幂等、actor 隔离和墓碑可见性沿用 L2。 运行一次指复用已提交结果,不重新派发可能已开始的尝试,不承诺最终一定完成。 +### 为什么不能先 close 再执行 L2 delete + +可靠 close 结算 SessionEnd 后永久停止原 Runtime;CLOSED/ARCHIVED 的 L2 删除 +明确不运行 Hook。组合两步无法在 End 之后、停机之前,用原 Runtime 结算 +SessionDelete。启动替代 worker 补跑 Delete 又会违反禁止重放的边界。L3 复用 +可靠 close 的停机机制和 L2 的原子退役事务,在永久 draining 前结算两个所需 +事件。单个 delete operation 在恢复期间持续保持 DELETING,不先公开完成一个 +CLOSE,再尝试无关的 L2 operation。effects receipt 让接管者跳过已提交 Hook +结果,不把 Harness 消失或 HTTP 应答当成完成证明。 + ## 2. 协议与证据 新增私有 `POST /session/:id/lifecycle`,携带 Session scope、operationId、 @@ -47,6 +57,17 @@ acquire、工具执行、输入和其他控制 mutation。仅当前 operation 可以在原 Workspace scope 获取生命周期 writer 和执行权限。该权限仍要求 私有认证和当前执行授权。使用既有锁层级消除授权与执行准入之间的竞态。 +placement guard 先于 retention、Session 和 journal 锁。仅检查 journal head +或尚不存在的围栏行不能排除并发插入围栏;writer 变更和普通执行授权必须共用 +准入的锁顺序。L3 之前的 Store 已经通过 Session 锁 helper 获取 publication +租户锁;新增的是 placement guard 和普通授权请求,其增量竞争和请求开销需要 +测量。围栏查询和变更使用既有哈希主键,同时保留原身份检查,避免锁定读取扫描 +其他租户的围栏;键编码与 Broker 共用,不新增索引。 +这保护所有 hosted attachment 的持久会话准入,包括没有本地 lifecycle +状态的 attachment,不代表所有会话都支持 L3 删除。明确的 Store 生命周期围栏 +拒绝返回 409;意外 Store、传输或 writer 权限故障返回 503,均不放行执行。 +protocol-zero close 保留限定的例外。 + 先结算此前操作,通用取消不得包含本 operation 的生命周期 occurrence。 稳定 occurrence ID 由 Session、operation、事件派生,复用 H2 的 catalog、plan、 dispatch intent 和结果。SessionEnd 的全部子执行提交结果后才开始 SessionDelete, @@ -81,6 +102,12 @@ head 排他,证明无已完成 bootstrap、有效 writer、journal 或 Hook 再保存 never-initialized 无 Hook 证据。有 header 时核对原 definition/catalog。 两种情况都必须在永久围栏之后检查完整 Runtime binding 集合。 +准入扫描并解析完整 journal,证明不存在已接纳但未结算的 Turn。成本随保留历史 +增长,事务期间持锁;尚未证明准入延迟存在固定上界。当前产品仅将 compaction +watermark 初始化为零,没有推进它的生产路径。既有冷恢复也不支持非零 watermark; +L3 返回 `workspace_lifecycle_journal_unverified`。未来 compaction 必须 +提供持久 idle-state 证据后,L3 才能接纳这些会话;本 PR 不实现压缩后的恢复。 + ## 4. 兼容与启用 新增迁移,保留 V32。升级前已接纳的操作沿用原协议与证据,不产生新 Hook 身份。 diff --git a/packages/cli/src/serve/hosted-harness-session.test.ts b/packages/cli/src/serve/hosted-harness-session.test.ts index 69585e0782f..b2fb92383c7 100644 --- a/packages/cli/src/serve/hosted-harness-session.test.ts +++ b/packages/cli/src/serve/hosted-harness-session.test.ts @@ -28,6 +28,7 @@ import { } from '@qwen-code/qwen-code-core/managed-runtime/local-jsonl-managed-session-journal-store.js'; import { resetManagedRuntimeDispatchGatesForTest } from '@qwen-code/qwen-code-core/managed-runtime/managed-runtime-dispatch-gate.js'; import { LocalManagedSessionAuthority } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-authority.js'; +import { ManagedSessionStoreHttpError } from '@qwen-code/qwen-code-core/managed-runtime/http-managed-session-store.js'; import { openManagedSession } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-assembly.js'; import { LocalShellResultCapture } from '@qwen-code/qwen-code-core/managed-runtime/local-shell-result-capture.js'; import { parseToolResultManifestBytes } from '@qwen-code/qwen-code-core/managed-runtime/managed-tool-result.js'; @@ -103,7 +104,10 @@ const state = vi.hoisted(() => ({ vi.mock( '@qwen-code/qwen-code-core/managed-runtime/http-managed-session-store.js', - () => ({ + async (importOriginal) => ({ + ...(await importOriginal< + typeof import('@qwen-code/qwen-code-core/managed-runtime/http-managed-session-store.js') + >()), HTTP_MANAGED_SESSION_STORE_CONTRACT: { maxInlineResourceBytes: 64 * 1024 }, createHttpManagedSessionStores: (options: { baseUrl: string; @@ -451,7 +455,13 @@ describe('Hosted Harness no-tool session', () => { it('authorizes detach after the coordinator recovers effects without a Harness lifecycle request', async () => { const { server, authorize, requests } = await hookApp(); - state.authorizeOrdinary.mockRejectedValue(new Error('DRAINING')); + state.authorizeOrdinary.mockRejectedValue( + new ManagedSessionStoreHttpError( + 409, + 'managed_session_lifecycle_active', + 'DRAINING', + ), + ); const authority = { operationId: 'recovered-effects', claimGeneration: 2 }; await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) .send({ authority }) @@ -468,7 +478,13 @@ describe('Hosted Harness no-tool session', () => { it('retains the attachment and restores authority when a recovered detach claim is rejected', async () => { const { server, authorize, requests } = await hookApp(); - state.authorizeOrdinary.mockRejectedValue(new Error('DRAINING')); + state.authorizeOrdinary.mockRejectedValue( + new ManagedSessionStoreHttpError( + 409, + 'managed_session_lifecycle_active', + 'DRAINING', + ), + ); state.authorizeLifecycle.mockRejectedValueOnce(new Error('stale claim')); await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) .send({ authority: { operationId: 'stale', claimGeneration: 1 } }) @@ -487,7 +503,13 @@ describe('Hosted Harness no-tool session', () => { it('rejects malformed detach authority without bypassing a persistent fence', async () => { const { server, authorize, requests } = await hookApp(); - state.authorizeOrdinary.mockRejectedValue(new Error('DRAINING')); + state.authorizeOrdinary.mockRejectedValue( + new ManagedSessionStoreHttpError( + 409, + 'managed_session_lifecycle_active', + 'DRAINING', + ), + ); await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) .send({ authority: { operationId: 'malformed', claimGeneration: 0 } }) .expect(400); @@ -502,7 +524,12 @@ describe('Hosted Harness no-tool session', () => { it('uses the legacy close admission for an attached protocol zero Session with Hooks', async () => { const { server, authorize, requests } = await hookApp(); state.authorizeOrdinary.mockImplementation(async (kind) => { - if (kind !== 'legacy-close') throw new Error('Ordinary admission closed'); + if (kind !== 'legacy-close') + throw new ManagedSessionStoreHttpError( + 409, + 'managed_session_lifecycle_active', + 'Ordinary admission closed', + ); }); await authorize(supertest(server).post(`/session/${SESSION_ID}/prompt`)) .send({}) @@ -518,6 +545,61 @@ describe('Hosted Harness no-tool session', () => { ).toEqual(['SessionEnd', 'SessionDelete']); }); + it.each([ + ['prompt', new TypeError('network timeout')], + ['detach', new TypeError('network timeout')], + [ + 'prompt', + new ManagedSessionStoreHttpError( + 503, + 'internal_error', + 'Store unavailable', + ), + ], + [ + 'detach', + new ManagedSessionStoreHttpError( + 503, + 'internal_error', + 'Store unavailable', + ), + ], + [ + 'prompt', + new ManagedSessionStoreHttpError( + 409, + 'managed_session_writer_conflict', + 'Writer fenced', + ), + ], + [ + 'detach', + new ManagedSessionStoreHttpError( + 409, + 'managed_session_writer_conflict', + 'Writer fenced', + ), + ], + ])( + 'reports an ordinary %s authorization failure as unavailable: %s', + async (route, cause) => { + const { server, authorize, requests } = await hookApp(); + state.authorizeOrdinary.mockRejectedValueOnce(cause); + const rejected = await authorize( + supertest(server).post(`/session/${SESSION_ID}/${route}`), + ).send({}); + expect(rejected.status).toBe(503); + expect(rejected.body.code).toBe( + 'hosted_execution_authorization_unavailable', + ); + expect(state.model).not.toHaveBeenCalled(); + expect(requests).toEqual([]); + await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .send({}) + .expect(204); + }, + ); + it('resumes the undispatched Delete Hook after revocation without cancelling or repeating End', async () => { const { server, authorize, requests } = await hookApp(); let permitted = true; diff --git a/packages/cli/src/serve/hosted-harness-session.ts b/packages/cli/src/serve/hosted-harness-session.ts index d9f6135a3d0..eac42f70614 100644 --- a/packages/cli/src/serve/hosted-harness-session.ts +++ b/packages/cli/src/serve/hosted-harness-session.ts @@ -30,6 +30,7 @@ import { import { createHttpManagedSessionStores, HTTP_MANAGED_SESSION_STORE_CONTRACT, + ManagedSessionStoreHttpError, type HttpToolPublicationOwner, type HttpManagedSessionStores, type ManagedSessionLifecycleAuthority, @@ -265,6 +266,16 @@ function error( ); } +function ordinaryAuthorizationError(res: Response, cause: unknown): void { + if ( + cause instanceof ManagedSessionStoreHttpError && + cause.status === 409 && + cause.remoteCode === 'managed_session_lifecycle_active' + ) + return error(res, 409, 'hosted_lifecycle_operation_active'); + error(res, 503, 'hosted_execution_authorization_unavailable'); +} + function identity( req: Request, sessions: Map, @@ -2146,8 +2157,8 @@ export function registerHostedHarnessSessionRoutes( ? 'legacy-close' : undefined, ); - } catch { - return error(res, 409, 'hosted_lifecycle_operation_active'); + } catch (cause) { + return ordinaryAuthorizationError(res, cause); } } next(); @@ -3702,8 +3713,8 @@ export function registerHostedHarnessSessionRoutes( } else if (req.method === 'POST') { try { await session.stores!.authorizeOrdinary(); - } catch { - return error(res, 409, 'hosted_lifecycle_operation_active'); + } catch (cause) { + return ordinaryAuthorizationError(res, cause); } } if (req.method === 'DELETE' && session.hooks) { diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java index 1c43983eff3..118ae42d3f1 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java @@ -30,6 +30,7 @@ import com.alibaba.qwen.code.managedagent.store.StoreModels.TurnRecord; import com.alibaba.qwen.code.managedagent.store.StoreModels.TurnSummary; import com.alibaba.qwen.code.managedagent.store.ManagedWorkspaceRegistry.ResolvedBinding; +import com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository; import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding; import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; import com.fasterxml.jackson.core.JsonProcessingException; @@ -1021,8 +1022,10 @@ private void syncLifecycleClaim(String tenantId, String sessionId, String operat if (!rows.isEmpty() && ((Number) rows.getFirst().get("lifecycle_protocol_version")).intValue() == 1) { var row = rows.getFirst(); jdbc.update("UPDATE qwen_runtime_harness_drain SET claim_generation = ?, claim_lease_until = ?" - + " WHERE tenant_id = ? AND harness_session_id = ? AND operation_id = ? AND phase = 'LIFECYCLE_ONLY'", - row.get("claim_generation"), row.get("lease_until"), tenantId, sessionId, operationId); + + " WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND harness_session_id = ?" + + " AND operation_id = ? AND phase = 'LIFECYCLE_ONLY'", + row.get("claim_generation"), row.get("lease_until"), JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), tenantId, sessionId, operationId); } } @@ -1039,8 +1042,9 @@ public void blockLifecycleOperation(String tenantId, String sessionId, String op + " AND lease_owner = ? AND claim_generation = ? AND lease_until > ?", failureCode, Math.addExact(now, Math.max(0, availableAt - clock.millis())), now, tenantId, sessionId, operationId, owner, generation, now); - jdbc.update("UPDATE qwen_runtime_harness_drain SET claim_lease_until = NULL WHERE tenant_id = ?" + jdbc.update("UPDATE qwen_runtime_harness_drain SET claim_lease_until = NULL WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ?" + " AND harness_session_id = ? AND operation_id = ? AND claim_generation = ?", + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), tenantId, sessionId, operationId, generation); } @@ -1062,8 +1066,9 @@ public void retryOperation(String tenantId, String sessionId, + " AND lease_owner = ? AND claim_generation = ? AND lease_until > ?", availableAt, now, tenantId, sessionId, operationId, owner, claimGeneration, now); - jdbc.update("UPDATE qwen_runtime_harness_drain SET claim_lease_until = NULL WHERE tenant_id = ?" + jdbc.update("UPDATE qwen_runtime_harness_drain SET claim_lease_until = NULL WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ?" + " AND harness_session_id = ? AND operation_id = ? AND claim_generation = ?", + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), tenantId, sessionId, operationId, claimGeneration); } diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java index 8ece63748fe..b9ef083252f 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java @@ -17,6 +17,7 @@ import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.StoredTransaction; import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.TransactionPage; import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.WriterGrant; +import com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository; import com.fasterxml.jackson.databind.JsonNode; import java.nio.ByteBuffer; import java.io.IOException; @@ -154,7 +155,9 @@ public void authorizeLifecycle(String tenantId, String sessionId, String writerT throw WorkspaceLifecycleStore.blocked("workspace_lifecycle_operation_conflict"); } } else if (!"DRAINING".equals(jdbc.queryForObject("SELECT phase FROM qwen_runtime_harness_drain" - + " WHERE tenant_id = ? AND harness_session_id = ?", String.class, tenantId, sessionId))) { + + " WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND harness_session_id = ?", String.class, + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, sessionId))) { throw WorkspaceLifecycleStore.blocked("workspace_lifecycle_hooks_unsettled"); } HeadRow head = requireHeadForUpdate(tenantId, sessionId); @@ -191,13 +194,17 @@ private void requireOrdinaryAdmission(String tenantId, String sessionId) { } private boolean lifecycleFenced(String tenantId, String sessionId) { - return !jdbc.queryForList("SELECT phase FROM qwen_runtime_harness_drain WHERE tenant_id = ?" - + " AND harness_session_id = ? FOR UPDATE", tenantId, sessionId).isEmpty(); + return !jdbc.queryForList("SELECT phase FROM qwen_runtime_harness_drain WHERE tenant_key = ? AND harness_key = ?" + + " AND tenant_id = ? AND harness_session_id = ? FOR UPDATE", + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, sessionId).isEmpty(); } private boolean lifecycleProtocolFenced(String tenantId, String sessionId) { - return !jdbc.queryForList("SELECT operation_id FROM qwen_runtime_harness_drain WHERE tenant_id = ?" - + " AND harness_session_id = ? AND operation_id IS NOT NULL FOR UPDATE", tenantId, sessionId).isEmpty(); + return !jdbc.queryForList("SELECT operation_id FROM qwen_runtime_harness_drain WHERE tenant_key = ? AND harness_key = ?" + + " AND tenant_id = ? AND harness_session_id = ? AND operation_id IS NOT NULL FOR UPDATE", + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, sessionId).isEmpty(); } @Autowired(required = false) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStore.java index e39db7656a6..1f81cb7be8c 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStore.java @@ -29,7 +29,9 @@ public static boolean legacyClose(JdbcTemplate jdbc, String tenant, String sessi + " WHERE o.tenant_id = ? AND o.session_id = ? AND o.operation_kind = 'CLOSE'" + " AND o.lifecycle_protocol_version = 0 AND o.delivery_state = 'LEASED'" + " AND o.lease_until > ? AND s.status = 'CLOSING'" - + " AND f.phase = 'DRAINING' AND f.operation_id IS NULL FOR UPDATE", tenant, session, now).isEmpty(); + + " AND f.tenant_key = ? AND f.harness_key = ?" + + " AND f.phase = 'DRAINING' AND f.operation_id IS NULL FOR UPDATE", tenant, session, now, + JdbcRuntimeBindingRepository.harnessDrainKey(tenant), JdbcRuntimeBindingRepository.harnessDrainKey(session)).isEmpty(); } private final JdbcTemplate jdbc; private final ObjectMapper json; @@ -59,7 +61,9 @@ public static void requireClaim(JdbcTemplate jdbc, String tenant, String session + " s.status, f.phase, f.operation_id FROM managed_agent_operation o" + " JOIN managed_agent_session s ON s.tenant_id = o.tenant_id AND s.session_id = o.session_id" + " JOIN qwen_runtime_harness_drain f ON f.tenant_id = s.tenant_id AND f.harness_session_id = s.session_id" - + " WHERE o.tenant_id = ? AND o.session_id = ? AND o.operation_id = ? FOR UPDATE", tenant, session, authority.operationId()); + + " WHERE o.tenant_id = ? AND o.session_id = ? AND o.operation_id = ?" + + " AND f.tenant_key = ? AND f.harness_key = ? FOR UPDATE", tenant, session, authority.operationId(), + JdbcRuntimeBindingRepository.harnessDrainKey(tenant), JdbcRuntimeBindingRepository.harnessDrainKey(session)); long now = jdbc.queryForObject("SELECT UNIX_TIMESTAMP(), EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(6))", (row, index) -> row.getLong(1) * 1000 + row.getLong(2) / 1000); if (rows.size() != 1) { @@ -175,7 +179,8 @@ private void saveLocked(OperationRecord operation, JsonNode receipt) { + " WHERE tenant_id = ? AND session_id = ? AND operation_id = ? AND claim_generation = ?", receipt.toString(), operation.tenantId(), operation.sessionId(), operation.operationId(), operation.claimGeneration()); jdbc.update("UPDATE qwen_runtime_harness_drain SET phase = 'DRAINING', claim_lease_until = NULL" - + " WHERE tenant_id = ? AND harness_session_id = ? AND operation_id = ?", + + " WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND harness_session_id = ? AND operation_id = ?", + JdbcRuntimeBindingRepository.harnessDrainKey(operation.tenantId()), JdbcRuntimeBindingRepository.harnessDrainKey(operation.sessionId()), operation.tenantId(), operation.sessionId(), operation.operationId()); } diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleMySqlIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleMySqlIT.java index 95fb6382bd1..b19dc0fb682 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleMySqlIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleMySqlIT.java @@ -6,6 +6,7 @@ import com.alibaba.qwen.code.managedagent.api.ApiException; import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.AcquireWriterRequest; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.AuthorizeLifecycleRequest; import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationKind; import java.util.UUID; import java.util.concurrent.CountDownLatch; @@ -82,6 +83,50 @@ void admissionSerializesAnOrdinaryWriterOnThePlacementFence() throws Exception { assertThat(fixture.jdbc.queryForObject("SELECT COUNT(*) FROM qwen_managed_session_journal_head", Integer.class)).isZero(); } + @Test + void ordinaryAuthorizationKeepsFenceLocksInsideTheTenant() throws Exception { + var fixture = fixture(); + var journal = new ManagedSessionStore(fixture.jdbc); + String token = "w".repeat(32); + String otherTenant = "other-tenant"; + String otherSession = "other-session"; + var original = fixture.transactions.execute(ignored -> journal.acquireWriter("tenant", fixture.session, + token, new AcquireWriterRequest("workspace", "writer", 60_000L))); + var neighbor = fixture.transactions.execute(ignored -> journal.acquireWriter("tenant", "neighbor-session", + token, new AcquireWriterRequest("workspace", "writer", 60_000L))); + var other = fixture.transactions.execute(ignored -> journal.acquireWriter(otherTenant, otherSession, + token, new AcquireWriterRequest("workspace", "writer", 60_000L))); + fixture.bindings.requestHarnessDrain("unrelated-tenant", "unrelated-session"); + var originalRequest = new AuthorizeLifecycleRequest("workspace", "writer", original.writerGeneration()); + var neighborRequest = new AuthorizeLifecycleRequest("workspace", "writer", neighbor.writerGeneration()); + var otherRequest = new AuthorizeLifecycleRequest("workspace", "writer", other.writerGeneration()); + try (var pool = Executors.newFixedThreadPool(2)) { + var entered = new CountDownLatch(2); + var sameTenant = new java.util.concurrent.atomic.AtomicReference>(); + fixture.transactions.executeWithoutResult(ignored -> { + journal.authorizeOrdinary("tenant", fixture.session, token, originalRequest); + sameTenant.set(pool.submit(() -> { + entered.countDown(); + fixture.transactions.executeWithoutResult(transaction -> + journal.authorizeOrdinary("tenant", "neighbor-session", token, neighborRequest)); + })); + var differentTenant = pool.submit(() -> { + entered.countDown(); + fixture.transactions.executeWithoutResult(transaction -> + journal.authorizeOrdinary(otherTenant, otherSession, token, otherRequest)); + }); + try { + assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue(); + assertThrows(TimeoutException.class, () -> sameTenant.get().get(100, TimeUnit.MILLISECONDS)); + differentTenant.get(5, TimeUnit.SECONDS); + } catch (Exception error) { + throw new IllegalStateException(error); + } + }); + sameTenant.get().get(5, TimeUnit.SECONDS); + } + } + @Test void aFailureAfterRetirementRollsBackTheEntireDeleteAndRetryReusesEffects() { var fixture = fixture(); diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/JdbcRuntimeBindingRepository.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/JdbcRuntimeBindingRepository.java index fd937180881..16aa8434de1 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/JdbcRuntimeBindingRepository.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/JdbcRuntimeBindingRepository.java @@ -293,6 +293,10 @@ public void requestHarnessDrain(String tenantId, String harnessSessionId) { }); } + public static String harnessDrainKey(String value) { + return JdbcRepositorySupport.valueKey(value); + } + @Override public boolean isHarnessDraining(String tenantId, String harnessSessionId) { return JdbcRepositorySupport.read(dataSource, connection -> { From 7462de8f5fff5bb3d9d3e5f36c104b217664d34c Mon Sep 17 00:00:00 2001 From: jinye Date: Sun, 4 Oct 2026 13:56:24 +0800 Subject: [PATCH 05/17] fix(managed-agent): resolve L3 migration version collision (#13354) --- docs/design/workspace-session-active-delete-l3.md | 3 ++- docs/design/workspace-session-active-delete-l3.zh-CN.md | 3 ++- ...sion_lifecycle.sql => V36__workspace_session_lifecycle.sql} | 0 3 files changed, 4 insertions(+), 2 deletions(-) rename packages/sdk-java/managed-agent-server/src/main/resources/db/migration/{V35__workspace_session_lifecycle.sql => V36__workspace_session_lifecycle.sql} (100%) diff --git a/docs/design/workspace-session-active-delete-l3.md b/docs/design/workspace-session-active-delete-l3.md index 1af8a602466..544594a345d 100644 --- a/docs/design/workspace-session-active-delete-l3.md +++ b/docs/design/workspace-session-active-delete-l3.md @@ -141,7 +141,8 @@ accept those Sessions; this PR does not implement compaction recovery. ## 4. Compatibility and rollout -Add a new migration; preserve V32. Historical admitted operations retain their +Add lifecycle migration V36 after the existing V35 tool-profile migration; +preserve V32. Historical admitted operations retain their original protocol and evidence, without new Hook identities. Live protocol-zero close attachments retain their legacy DELETE and original Hook control path only while their persisted close claim is valid. Ordinary diff --git a/docs/design/workspace-session-active-delete-l3.zh-CN.md b/docs/design/workspace-session-active-delete-l3.zh-CN.md index 370837734cf..0bfea3d6bde 100644 --- a/docs/design/workspace-session-active-delete-l3.zh-CN.md +++ b/docs/design/workspace-session-active-delete-l3.zh-CN.md @@ -110,7 +110,8 @@ L3 返回 `workspace_lifecycle_journal_unverified`。未来 compaction 必须 ## 4. 兼容与启用 -新增迁移,保留 V32。升级前已接纳的操作沿用原协议与证据,不产生新 Hook 身份。 +在已有 V35 工具配置迁移之后新增 V36 生命周期迁移,保留 V32。 +升级前已接纳的操作沿用原协议与证据,不产生新 Hook 身份。 仍存活的 protocol-zero close attachment 仅在持久 close claim 有效时保留原 DELETE 和原 Hook control 路径。普通执行继续被围栏阻止;此例外不能授权 L3 或 MCP 执行。 启用 L3 准入前升级全部 coordinator 和 Harness。缺少新协议能力时拒绝准入, diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V35__workspace_session_lifecycle.sql b/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V36__workspace_session_lifecycle.sql similarity index 100% rename from packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V35__workspace_session_lifecycle.sql rename to packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V36__workspace_session_lifecycle.sql From 63e5dbc28efacf54b8cf63a8ecb5def8d7d01aa7 Mon Sep 17 00:00:00 2001 From: jinye Date: Sun, 4 Oct 2026 21:32:09 +0800 Subject: [PATCH 06/17] fix(managed-agent): address PR review feedback (#13354) --- .../workspace-session-active-delete-l3.md | 27 +- ...orkspace-session-active-delete-l3.zh-CN.md | 18 +- .../src/serve/hosted-harness-session.test.ts | 230 +++++++++++++----- .../cli/src/serve/hosted-harness-session.ts | 24 +- .../http-managed-session-store.test.ts | 57 +++++ .../http-managed-session-store.ts | 9 +- .../managed-session-assembly.test.ts | 53 +++- .../managed-session-assembly.ts | 18 +- .../harness/QwenHostedHarnessConnector.java | 10 +- .../store/ManagedExtensionRecordStore.java | 16 +- .../store/ManagedSessionStore.java | 9 +- ...l => V40__workspace_session_lifecycle.sql} | 0 .../QwenHostedHarnessConnectorTest.java | 40 +++ .../store/WorkspaceLifecycleStoreTest.java | 85 +++++++ .../qwen/code/daemon/HostedHarnessClient.java | 13 + .../code/daemon/HostedHarnessClientTest.java | 25 ++ 16 files changed, 535 insertions(+), 99 deletions(-) rename packages/sdk-java/managed-agent-server/src/main/resources/db/migration/{V36__workspace_session_lifecycle.sql => V40__workspace_session_lifecycle.sql} (100%) diff --git a/docs/design/workspace-session-active-delete-l3.md b/docs/design/workspace-session-active-delete-l3.md index 544594a345d..8b9fe10c9e3 100644 --- a/docs/design/workspace-session-active-delete-l3.md +++ b/docs/design/workspace-session-active-delete-l3.md @@ -83,7 +83,10 @@ This protects persisted Session admission on every hosted attachment, including one without local lifecycle state; it does not grant every Session L3 delete support. A definite Store lifecycle-fence rejection returns 409. Unexpected Store, transport or writer-authority failures return -503 and admit no execution. Protocol-zero close retains its scoped exception. +503 and admit no execution. Authenticated cancellation of an admitted Turn is a +local abort and does not require ordinary Store authorization; the local +lifecycle fence still excludes cancellation. Protocol-zero close retains its +scoped exception. Settle earlier operations, excluding this operation's lifecycle occurrences from generic cancellation. Stable occurrence IDs derive from Session, operation and @@ -115,8 +118,22 @@ Receipt recovery may skip the Harness lifecycle request entirely. Detach must therefore validate its operation authority against Session Store even when the live attachment has no local lifecycle state. Store authorization checks the current claim, original writer, saved effects and DRAINING fence; a rejected -claim leaves the attachment and its prior authority intact. Ordinary detach -still requires ordinary execution authorization. +claim leaves the attachment and its prior authority intact. A successor without +a cached attachment addresses the original Session ID with its current claim; +it does not load a Runtime or dispatch Hooks to recover a client ID. Missing +authority still requires the original client ID, and a supplied wrong client ID +is rejected. Cleanup authorization accepts only the same writer identity, token +and generation, including an expired or already sealed writer; it does not renew +that writer. Hook dispatch still requires an active, unexpired writer. Ordinary +detach still requires ordinary execution authorization. + +After that cleanup authorization, stop activation renewal and seal the original +writer without appending an activation release record. Expired or sealed writers +cannot append, and an already-started renewal remains constrained by the Store +fence and writer seal. The historical activation may still read as active; it is +not evidence of a normal release. Completion relies on the permanent fence, +effects receipt, writer seal and original Runtime stop proofs. Ordinary and +legacy close continue to record activation release before sealing. Final completion requires the live delivery claim, effects receipt, permanent fence, no live writer or unsettled execution, and verifiable original stop @@ -141,8 +158,8 @@ accept those Sessions; this PR does not implement compaction recovery. ## 4. Compatibility and rollout -Add lifecycle migration V36 after the existing V35 tool-profile migration; -preserve V32. Historical admitted operations retain their +Add lifecycle migration V40 after the existing V35 tool-profile and V36–V39 +journal/query migrations; preserve those migrations and V32. Historical admitted operations retain their original protocol and evidence, without new Hook identities. Live protocol-zero close attachments retain their legacy DELETE and original Hook control path only while their persisted close claim is valid. Ordinary diff --git a/docs/design/workspace-session-active-delete-l3.zh-CN.md b/docs/design/workspace-session-active-delete-l3.zh-CN.md index 0bfea3d6bde..1becd2ee545 100644 --- a/docs/design/workspace-session-active-delete-l3.zh-CN.md +++ b/docs/design/workspace-session-active-delete-l3.zh-CN.md @@ -66,7 +66,8 @@ placement guard 先于 retention、Session 和 journal 锁。仅检查 journal h 这保护所有 hosted attachment 的持久会话准入,包括没有本地 lifecycle 状态的 attachment,不代表所有会话都支持 L3 删除。明确的 Store 生命周期围栏 拒绝返回 409;意外 Store、传输或 writer 权限故障返回 503,均不放行执行。 -protocol-zero close 保留限定的例外。 +已接纳 Turn 的认证取消是本地 abort,不要求普通 Store 授权;本地生命周期围栏 +仍禁止取消生命周期操作。protocol-zero close 保留限定的例外。 先结算此前操作,通用取消不得包含本 operation 的生命周期 occurrence。 稳定 occurrence ID 由 Session、operation、事件派生,复用 H2 的 catalog、plan、 @@ -91,7 +92,17 @@ receipt 已保存时跳过 Hook,否则从相同 H2 occurrence 恢复进度。H receipt 恢复可能完全跳过 Harness lifecycle 请求。因此,即使存活 attachment 没有本地 lifecycle 状态,detach 也必须向 Session Store 核验 operation 权限。 Store 授权检查当前 claim、原 writer、已保存的 effects 与 DRAINING 围栏; -claim 被拒绝时保留 attachment 及其原权限。普通 detach 仍须通过普通执行授权。 +claim 被拒绝时保留 attachment 及其原权限。没有缓存 attachment 的接管者凭 +当前 claim 直接访问原 Session ID,不为恢复 client ID 加载 Runtime 或派发 Hook。 +缺少 authority 时仍要求原 client ID,提供错误 client ID 也会被拒绝。普通 detach +仍须通过普通执行授权。清理授权仅接受原 writer 身份、token 与 generation, +包括已过期或已封存的 writer,且不续租;Hook 派发仍要求 ACTIVE 且未过期的 writer。 + +通过该清理授权后,停止 activation 续租并封存原 writer,不追加 activation release +记录。过期或已封存的 writer 无权追加;已开始的续租仍受 Store 围栏和 writer seal +约束。历史 activation 可能仍显示 active,不能作为正常释放的证据。完成依据仍为 +永久围栏、effects receipt、writer 封存与原 Runtime 停机证明。普通与 legacy close +仍在封存前记录 activation release。 最终完成要求有效 delivery claim、effects receipt、永久围栏、无有效 writer 或 未结算执行,以及可核验的原停机证明。CLOSE 提交 CLOSED;DELETE 原子提交 O4 @@ -110,7 +121,8 @@ L3 返回 `workspace_lifecycle_journal_unverified`。未来 compaction 必须 ## 4. 兼容与启用 -在已有 V35 工具配置迁移之后新增 V36 生命周期迁移,保留 V32。 +在已有 V35 工具配置及 V36–V39 journal/查询迁移之后新增 V40 生命周期迁移, +保留这些迁移和 V32。 升级前已接纳的操作沿用原协议与证据,不产生新 Hook 身份。 仍存活的 protocol-zero close attachment 仅在持久 close claim 有效时保留原 DELETE 和原 Hook control 路径。普通执行继续被围栏阻止;此例外不能授权 L3 或 MCP 执行。 diff --git a/packages/cli/src/serve/hosted-harness-session.test.ts b/packages/cli/src/serve/hosted-harness-session.test.ts index b2fb92383c7..bffd2368ade 100644 --- a/packages/cli/src/serve/hosted-harness-session.test.ts +++ b/packages/cli/src/serve/hosted-harness-session.test.ts @@ -445,6 +445,9 @@ describe('Hosted Harness no-tool session', () => { await authorize(supertest(server).post(`/session/${SESSION_ID}/prompt`)) .send({}) .expect(409); + await authorize(supertest(server).post(`/session/${SESSION_ID}/cancel`)) + .send({}) + .expect(409); await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) .send({ authority: { operationId: 'op-l3', claimGeneration: 2 } }) .expect(204); @@ -453,73 +456,110 @@ describe('Hosted Harness no-tool session', () => { }, ); - it('authorizes detach after the coordinator recovers effects without a Harness lifecycle request', async () => { - const { server, authorize, requests } = await hookApp(); - state.authorizeOrdinary.mockRejectedValue( - new ManagedSessionStoreHttpError( - 409, - 'managed_session_lifecycle_active', - 'DRAINING', - ), - ); - const authority = { operationId: 'recovered-effects', claimGeneration: 2 }; - await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) - .send({ authority }) - .expect(204); - expect(state.setLifecycleAuthority).toHaveBeenLastCalledWith(authority); - expect(state.authorizeLifecycle).toHaveBeenCalledExactlyOnceWith(); - expect(state.authorizeOrdinary).not.toHaveBeenCalled(); - expect(requests).toEqual([]); - expect(state.model).not.toHaveBeenCalled(); - await authorize(supertest(server).post(`/session/${SESSION_ID}/prompt`)) - .send({}) - .expect(404); - }); + it.each([true, false])( + 'authorizes detach after the coordinator recovers effects without a Harness lifecycle request (clientId=%s)', + async (withClientId) => { + const { server, authorize, requests } = await hookApp(); + const transcriptPath = path.join(state.root, `${SESSION_ID}.jsonl`); + const transcript = await readFile(transcriptPath, 'utf8'); + const releaseActivation = vi + .spyOn(LocalManagedSessionAuthority.prototype, 'releaseActivation') + .mockRejectedValue(new Error('journal appends are fenced')); + state.authorizeOrdinary.mockRejectedValue( + new ManagedSessionStoreHttpError( + 409, + 'managed_session_lifecycle_active', + 'DRAINING', + ), + ); + const authority = { + operationId: 'recovered-effects', + claimGeneration: 2, + }; + await headers(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .send({}) + .expect(404); + await headers(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .set('X-Qwen-Client-Id', 'wrong-client') + .send({ authority }) + .expect(404); + expect(state.authorizeLifecycle).not.toHaveBeenCalled(); + await (withClientId ? authorize : headers)( + supertest(server).post(`/session/${SESSION_ID}/detach`), + ) + .send({ authority }) + .expect(204); + expect(state.setLifecycleAuthority).toHaveBeenLastCalledWith(authority); + expect(state.authorizeLifecycle).toHaveBeenCalledExactlyOnceWith(); + expect(state.authorizeOrdinary).not.toHaveBeenCalled(); + expect(releaseActivation).not.toHaveBeenCalled(); + expect(await readFile(transcriptPath, 'utf8')).toBe(transcript); + expect(requests).toEqual([]); + expect(state.model).not.toHaveBeenCalled(); + await authorize(supertest(server).post(`/session/${SESSION_ID}/prompt`)) + .send({}) + .expect(404); + }, + ); - it('retains the attachment and restores authority when a recovered detach claim is rejected', async () => { - const { server, authorize, requests } = await hookApp(); - state.authorizeOrdinary.mockRejectedValue( - new ManagedSessionStoreHttpError( - 409, - 'managed_session_lifecycle_active', - 'DRAINING', - ), - ); - state.authorizeLifecycle.mockRejectedValueOnce(new Error('stale claim')); - await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) - .send({ authority: { operationId: 'stale', claimGeneration: 1 } }) - .expect(503); - expect(state.setLifecycleAuthority).toHaveBeenLastCalledWith(undefined); - expect(requests).toEqual([]); - await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) - .send({}) - .expect(409); - await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) - .send({ authority: { operationId: 'valid', claimGeneration: 2 } }) - .expect(204); - expect(state.authorizeLifecycle).toHaveBeenCalledTimes(2); - expect(requests).toEqual([]); - }); + it.each([true, false])( + 'retains the attachment and restores authority when a recovered detach claim is rejected (clientId=%s)', + async (withClientId) => { + const { server, authorize, requests } = await hookApp(); + state.authorizeOrdinary.mockRejectedValue( + new ManagedSessionStoreHttpError( + 409, + 'managed_session_lifecycle_active', + 'DRAINING', + ), + ); + state.authorizeLifecycle.mockRejectedValueOnce(new Error('stale claim')); + await (withClientId ? authorize : headers)( + supertest(server).post(`/session/${SESSION_ID}/detach`), + ) + .send({ authority: { operationId: 'stale', claimGeneration: 1 } }) + .expect(503); + expect(state.setLifecycleAuthority).toHaveBeenLastCalledWith(undefined); + expect(requests).toEqual([]); + await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .send({}) + .expect(409); + await (withClientId ? authorize : headers)( + supertest(server).post(`/session/${SESSION_ID}/detach`), + ) + .send({ authority: { operationId: 'valid', claimGeneration: 2 } }) + .expect(204); + expect(state.authorizeLifecycle).toHaveBeenCalledTimes(2); + expect(requests).toEqual([]); + }, + ); - it('rejects malformed detach authority without bypassing a persistent fence', async () => { - const { server, authorize, requests } = await hookApp(); - state.authorizeOrdinary.mockRejectedValue( - new ManagedSessionStoreHttpError( - 409, - 'managed_session_lifecycle_active', - 'DRAINING', - ), - ); - await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) - .send({ authority: { operationId: 'malformed', claimGeneration: 0 } }) - .expect(400); - expect(state.authorizeLifecycle).not.toHaveBeenCalled(); - expect(state.setLifecycleAuthority).not.toHaveBeenCalled(); - await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) - .send({ authority: { operationId: 'valid', claimGeneration: 2 } }) - .expect(204); - expect(requests).toEqual([]); - }); + it.each([true, false])( + 'rejects malformed detach authority without bypassing a persistent fence (clientId=%s)', + async (withClientId) => { + const { server, authorize, requests } = await hookApp(); + state.authorizeOrdinary.mockRejectedValue( + new ManagedSessionStoreHttpError( + 409, + 'managed_session_lifecycle_active', + 'DRAINING', + ), + ); + await (withClientId ? authorize : headers)( + supertest(server).post(`/session/${SESSION_ID}/detach`), + ) + .send({ authority: { operationId: 'malformed', claimGeneration: 0 } }) + .expect(400); + expect(state.authorizeLifecycle).not.toHaveBeenCalled(); + expect(state.setLifecycleAuthority).not.toHaveBeenCalled(); + await (withClientId ? authorize : headers)( + supertest(server).post(`/session/${SESSION_ID}/detach`), + ) + .send({ authority: { operationId: 'valid', claimGeneration: 2 } }) + .expect(204); + expect(requests).toEqual([]); + }, + ); it('uses the legacy close admission for an attached protocol zero Session with Hooks', async () => { const { server, authorize, requests } = await hookApp(); @@ -600,6 +640,66 @@ describe('Hosted Harness no-tool session', () => { }, ); + it.each([ + new TypeError('network timeout'), + new ManagedSessionStoreHttpError( + 503, + 'internal_error', + 'Store unavailable', + ), + new ManagedSessionStoreHttpError( + 409, + 'managed_session_writer_conflict', + 'Writer fenced', + ), + ])( + 'cancels an active Turn despite ordinary authorization failure: %s', + async (cause) => { + const { server, authorize } = await hookApp(); + let signal!: AbortSignal; + let finish!: () => void; + state.model.mockImplementationOnce( + (input) => + new Promise((resolve) => { + signal = input.signal; + finish = () => resolve({ text: '', model: 'test-model' }); + signal.addEventListener('abort', finish, { once: true }); + }), + ); + const prompt = [{ type: 'text', text: 'wait for cancellation' }]; + const payloadDigest = `sha256:${createHash('sha256').update(JSON.stringify(prompt)).digest('hex')}`; + await authorize(supertest(server).post(`/session/${SESSION_ID}/prompt`)) + .send({ prompt, promptId: PROMPT_ID, payloadDigest }) + .expect(202); + await vi.waitFor(() => expect(state.model).toHaveBeenCalledTimes(1)); + state.authorizeOrdinary.mockClear(); + state.authorizeOrdinary.mockRejectedValue(cause); + try { + await headers( + supertest(server).post(`/session/${SESSION_ID}/cancel`), + ).expect(404); + expect(signal.aborted).toBe(false); + await authorize( + supertest(server).post(`/session/${SESSION_ID}/cancel`), + ).expect(204); + expect(signal.aborted).toBe(true); + expect(state.authorizeOrdinary).not.toHaveBeenCalled(); + } finally { + finish(); + } + await vi.waitFor(async () => { + const status = await authorize( + supertest(server).get(`/session/${SESSION_ID}/status`), + ); + expect(status.body.hasActivePrompt).toBe(false); + }); + state.authorizeOrdinary.mockResolvedValue(undefined); + await authorize( + supertest(server).post(`/session/${SESSION_ID}/detach`), + ).expect(204); + }, + ); + it('resumes the undispatched Delete Hook after revocation without cancelling or repeating End', async () => { const { server, authorize, requests } = await hookApp(); let permitted = true; diff --git a/packages/cli/src/serve/hosted-harness-session.ts b/packages/cli/src/serve/hosted-harness-session.ts index eac42f70614..eafcc375ff8 100644 --- a/packages/cli/src/serve/hosted-harness-session.ts +++ b/packages/cli/src/serve/hosted-harness-session.ts @@ -2148,7 +2148,7 @@ export function registerHostedHarnessSessionRoutes( if ( session && req.method !== 'GET' && - !['/lifecycle', '/detach', '/heartbeat'].includes(req.path) && + !['/lifecycle', '/detach', '/heartbeat', '/cancel'].includes(req.path) && !session.lifecycle ) { try { @@ -3674,7 +3674,17 @@ export function registerHostedHarnessSessionRoutes( res: Response, allowMissingClientId = false, ): Promise => { - const session = identity(req, sessions, allowMissingClientId); + let authority: ManagedSessionLifecycleAuthority | undefined; + try { + authority = lifecycleAuthority(object(req.body)?.['authority']); + } catch { + return error(res, 400, 'invalid_hosted_lifecycle_authority'); + } + const session = identity( + req, + sessions, + allowMissingClientId || authority !== undefined, + ); if (!session) return error(res, 404, 'hosted_session_not_found'); if ( session.active || @@ -3683,12 +3693,6 @@ export function registerHostedHarnessSessionRoutes( session.hooksBusy ) return error(res, 409, 'hosted_turn_active'); - let authority: ManagedSessionLifecycleAuthority | undefined; - try { - authority = lifecycleAuthority(object(req.body)?.['authority']); - } catch { - return error(res, 400, 'invalid_hosted_lifecycle_authority'); - } session.mcpBusy = true; session.mcpClosing = true; try { @@ -3742,7 +3746,9 @@ export function registerHostedHarnessSessionRoutes( // the Workspace stays pinned after every later route is gone. await releaseLeaseNow(session); await session.mcp?.close(); - await session.managed.close(); + await session.managed.close( + session.lifecycle ? { releaseActivation: false } : undefined, + ); for (const stop of session.streams) stop(); sessions.delete(req.params['id']); res.sendStatus(204); diff --git a/packages/core/src/managed-runtime/http-managed-session-store.test.ts b/packages/core/src/managed-runtime/http-managed-session-store.test.ts index b449eb9c592..db0bbaf89e9 100644 --- a/packages/core/src/managed-runtime/http-managed-session-store.test.ts +++ b/packages/core/src/managed-runtime/http-managed-session-store.test.ts @@ -224,6 +224,63 @@ describe('HTTP Managed Session store', () => { } }); + it('authorizes cleanup with the original expired or sealed writer without renewing it', async () => { + vi.useFakeTimers({ now: 1_790_000_000_000, toFake: ['Date'] }); + const server = new FakeManagedSessionStore(); + const authorize = vi.fn(); + const stores = createHttpManagedSessionStores({ + baseUrl: 'http://session-store.test', + sessionKey: SESSION_KEY, + writerId: 'harness-a', + writerToken: TOKEN_A, + fetchFn: async (input, init) => { + const path = new URL(requestUrl(input)).pathname; + if (path.endsWith('/writers:renew')) + throw new Error('Original writer lease expired'); + if (path.endsWith('/lifecycle:authorize')) { + authorize(); + expect(JSON.parse(String(init?.body))).toEqual({ + workspaceId: SESSION_KEY.workspaceId, + writerId: 'harness-a', + writerGeneration: 1, + }); + const headers = new Headers(init?.headers); + expect(headers.get('X-Qwen-Managed-Writer-Token')).toBe(TOKEN_A); + expect(headers.get('X-Qwen-Lifecycle-Operation-Id')).toBe( + 'delete-original', + ); + expect(headers.get('X-Qwen-Lifecycle-Claim-Generation')).toBe('2'); + return jsonResponse({}); + } + return server.fetch(input, init); + }, + }); + try { + await stores.journalStore.open({ sessionKey: SESSION_KEY }); + vi.setSystemTime(1_790_000_400_000); + stores.setLifecycleAuthority({ + operationId: 'delete-original', + claimGeneration: 2, + }); + await stores.authorizeLifecycle(); + expect(authorize).toHaveBeenCalledTimes(1); + await expect(stores.authorizeLifecycle('delete')).rejects.toThrow( + 'Original writer lease expired', + ); + expect(authorize).toHaveBeenCalledTimes(1); + await stores.close(); + await stores.authorizeLifecycle(); + expect(authorize).toHaveBeenCalledTimes(2); + await expect(stores.authorizeLifecycle('delete')).rejects.toThrow( + 'writer is not active', + ); + expect(authorize).toHaveBeenCalledTimes(2); + } finally { + await stores.close(); + vi.useRealTimers(); + } + }); + it('publishes bounded tool output immediately under the original writer grant', async () => { const server = new FakeManagedSessionStore(); let publication: Record | undefined; diff --git a/packages/core/src/managed-runtime/http-managed-session-store.ts b/packages/core/src/managed-runtime/http-managed-session-store.ts index 8c0aa2164f0..0dcd47acdcd 100644 --- a/packages/core/src/managed-runtime/http-managed-session-store.ts +++ b/packages/core/src/managed-runtime/http-managed-session-store.ts @@ -429,12 +429,17 @@ class ManagedSessionStoreHttpClient { } async authorizeLifecycle(kind?: 'close' | 'delete'): Promise { - await this.assertWritable(); + if (kind) await this.assertWritable(); + const grant = this.grant; + if (!grant) + throw new ManagedSessionRecordError( + 'the HTTP Managed Session writer is not active.', + ); await this.json('/lifecycle:authorize', 'POST', { ...(kind ? { kind } : {}), workspaceId: this.sessionKey.workspaceId, writerId: this.writerId, - writerGeneration: this.grant!.writerGeneration, + writerGeneration: grant.writerGeneration, }); } private readonly publicationAdmissions = new Map(); diff --git a/packages/core/src/managed-runtime/managed-session-assembly.test.ts b/packages/core/src/managed-runtime/managed-session-assembly.test.ts index 0ed4be1b831..b5a0e2c612a 100644 --- a/packages/core/src/managed-runtime/managed-session-assembly.test.ts +++ b/packages/core/src/managed-runtime/managed-session-assembly.test.ts @@ -7,7 +7,7 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { Storage } from '../config/storage.js'; import type { ChatRecord } from '../services/chatRecordingService.js'; import { SessionWriterLease } from '../services/session-writer-lease.js'; @@ -35,6 +35,8 @@ const sessionKey = { tenantId: 't1', workspaceId: 'w1', sessionId }; const temporaryDirectories = new Set(); afterEach(async () => { + vi.useRealTimers(); + vi.restoreAllMocks(); for (const directory of temporaryDirectories) { await fs.rm(directory, { recursive: true, force: true }); } @@ -227,6 +229,7 @@ describe('managed session assembly', () => { const session = await open(workspace); await session.sink.write(record({ uuid: 'rec-user-1' })); await session.close(); + expect(session.authority.currentActivation?.phase).toBe('released'); await expect( SessionWriterLease.acquire({ @@ -237,6 +240,54 @@ describe('managed session assembly', () => { ).rejects.toThrow(); }); + it('stops renewal and seals an externally fenced writer without appending activation release', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); + const workspace = await createWorkspace(); + const session = await open(workspace); + const committed = session.authority.commitProof; + const transcript = await fs.readFile(workspace.transcriptPath, 'utf8'); + let finishRenewal!: () => void; + const pendingRenewal = new Promise((resolve) => { + finishRenewal = resolve; + }); + const renew = vi + .spyOn(session.authority, 'renewActivation') + .mockImplementation(async () => { + await pendingRenewal; + return undefined; + }); + const release = vi + .spyOn(session.authority, 'releaseActivation') + .mockRejectedValue(new Error('journal appends are fenced')); + + try { + await vi.advanceTimersByTimeAsync(20_000); + expect(renew).toHaveBeenCalledTimes(1); + await session.close({ releaseActivation: false }); + await vi.advanceTimersByTimeAsync(60_000); + expect(renew).toHaveBeenCalledTimes(1); + expect(release).not.toHaveBeenCalled(); + expect(await fs.readFile(workspace.transcriptPath, 'utf8')).toBe( + transcript, + ); + expect(session.authority.commitProof).toEqual(committed); + expect(session.authority.currentActivation?.phase).toBe('active'); + const lockPath = path.join( + workspace.runtimeBaseDir, + 'tmp', + 'session-writer-locks', + `${encodeURIComponent(sessionId)}.lock`, + ); + expect(JSON.parse(await fs.readFile(lockPath, 'utf8'))).toMatchObject({ + state: 'sealed', + last_commit_sequence: committed.lastCommitSequence, + committed_prefix_hash: committed.committedPrefixHash, + }); + } finally { + finishRenewal(); + } + }); + it('seals with the commit proof and reopens through a certified takeover', async () => { const workspace = await createWorkspace(); const session = await open(workspace); diff --git a/packages/core/src/managed-runtime/managed-session-assembly.ts b/packages/core/src/managed-runtime/managed-session-assembly.ts index 799cad21f89..faf24f77064 100644 --- a/packages/core/src/managed-runtime/managed-session-assembly.ts +++ b/packages/core/src/managed-runtime/managed-session-assembly.ts @@ -136,8 +136,11 @@ export interface ManagedSession { readonly activationId: string; readonly epoch: number; }>; - /** Seals the writer, leaving the at-rest barrier in place. */ - close(): Promise; + /** + * Seals the writer, leaving the at-rest barrier in place. Omit activation + * release only when an external permanent lifecycle fence forbids appends. + */ + close(options?: { readonly releaseActivation?: boolean }): Promise; } /** @@ -253,13 +256,14 @@ export async function openManagedSession( return activation; }, // Sealing is the at-rest barrier, but only the lease's owner may end it. - // A call that owns the whole lifecycle also records the boundary, or the - // activation would read as abandoned. An adopted lease still stops the - // renewal it started; only the lease itself stays with its owner. - close: async () => { + // Normally the lifecycle owner also records the boundary, or the activation + // reads as abandoned. An external permanent fence can forbid that append. + // An adopted lease stops renewal but leaves the lease with its owner. + close: async (options) => { renewal.stop(); if (adopted) return; - await authority.releaseActivation(); + if (options?.releaseActivation !== false) + await authority.releaseActivation(); await authority.close(); }, }; diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java index a7c16b66616..5a5ce13b871 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java @@ -137,11 +137,13 @@ public com.fasterxml.jackson.databind.JsonNode settleLifecycle( public void detachLifecycle(com.alibaba.qwen.code.managedagent.store.StoreModels.OperationRecord operation) { AttachmentKey key = new AttachmentKey(operation.tenantId(), operation.sessionId()); HarnessSessionRef ref = attachments.get(key); - if (ref == null) { - return; - } try { - client().detachLifecycle(ref, Map.of("operationId", operation.operationId(), "claimGeneration", operation.claimGeneration())); + var authority = Map.of("operationId", operation.operationId(), "claimGeneration", operation.claimGeneration()); + if (ref == null) { + client().detachLifecycle(operation.sessionId(), authority); + } else { + client().detachLifecycle(ref, authority); + } } catch (DaemonHttpException error) { if (error.getStatusCode() != 404) { throw error; diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedExtensionRecordStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedExtensionRecordStore.java index 1340c27ebd1..7b2543fbd34 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedExtensionRecordStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedExtensionRecordStore.java @@ -270,7 +270,13 @@ boolean hasNewLifecycleDispatch(String tenantId, String sessionId, byte[] bytes, Function resources) { boolean dispatch = false; for (String line : new String(bytes, StandardCharsets.UTF_8).split("\n")) { - JsonNode event = parse(line).path("managedSession"); + JsonNode parsed = parse(line); + if (parsed == null) { + throw new ApiException(HttpStatus.BAD_REQUEST, + ManagedSessionStoreModels.ERROR_INVALID_REQUEST, + "Record line is not a JSON object the Session authority can read."); + } + JsonNode event = parsed.path("managedSession"); JsonNode payload = event.path("payload"); if ("input.accepted".equals(event.path("kind").asText()) || "tool.intent".equals(event.path("kind").asText())) { @@ -304,7 +310,13 @@ boolean hasNewLifecycleDispatch(String tenantId, String sessionId, byte[] bytes, void requireLifecycleSettlement(String tenantId, String sessionId, byte[] bytes, Function resources) { for (String line : new String(bytes, StandardCharsets.UTF_8).split("\n")) { - JsonNode event = parse(line).path("managedSession"); + JsonNode parsed = parse(line); + if (parsed == null) { + throw new ApiException(HttpStatus.BAD_REQUEST, + ManagedSessionStoreModels.ERROR_INVALID_REQUEST, + "Record line is not a JSON object the Session authority can read."); + } + JsonNode event = parsed.path("managedSession"); String kind = event.path("kind").asText(); JsonNode payload = event.path("payload"); if ("input.accepted".equals(kind) || "tool.intent".equals(kind) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java index b9ef083252f..020ce7a0f40 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java @@ -162,7 +162,14 @@ public void authorizeLifecycle(String tenantId, String sessionId, String writerT } HeadRow head = requireHeadForUpdate(tenantId, sessionId); requireHeadScope(head, tenantId, request.workspaceId(), sessionId); - requireWriter(head, request.writerId(), request.writerGeneration(), writerToken, databaseNow(), true); + if (request.kind() != null) { + requireWriter(head, request.writerId(), request.writerGeneration(), writerToken, databaseNow(), true); + } else if (!List.of("ACTIVE", "SEALED").contains(head.state()) + || request.writerGeneration() != head.writerGeneration() + || !request.writerId().equals(head.writerId()) + || !secureEquals(tokenHash(writerToken), head.leaseTokenHash())) { + throw writerConflict(); + } } private void checkLifecycleWriter(String tenantId, String sessionId, diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V36__workspace_session_lifecycle.sql b/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V40__workspace_session_lifecycle.sql similarity index 100% rename from packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V36__workspace_session_lifecycle.sql rename to packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V40__workspace_session_lifecycle.sql diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java index ec56302cd35..8b9f36a64c7 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java @@ -25,6 +25,7 @@ import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; import com.alibaba.qwen.code.managedagent.store.AgentStateStore; import com.alibaba.qwen.code.managedagent.store.ManagedActionStore; +import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationRecord; import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionRecord; import com.alibaba.qwen.code.managedagent.store.WorkspaceExecutionStore; import com.alibaba.qwen.code.managedagent.store.WriterCredentialPolicy; @@ -48,6 +49,45 @@ class QwenHostedHarnessConnectorTest { private static final String BOOT_ID = "11111111-1111-4111-8111-111111111111"; + @Test + void successorDetachesTheOriginalSessionWithoutLoadingAnAttachment() { + HostedHarnessClient client = mock(HostedHarnessClient.class); + QwenHostedHarnessConnector successor = connector(client); + OperationRecord operation = mock(OperationRecord.class); + when(operation.tenantId()).thenReturn("tenant-a"); + when(operation.sessionId()).thenReturn(SESSION_ID); + when(operation.operationId()).thenReturn("delete-1"); + when(operation.claimGeneration()).thenReturn(2L); + + successor.detachLifecycle(operation); + + verify(client).detachLifecycle(SESSION_ID, Map.of("operationId", "delete-1", "claimGeneration", 2L)); + verify(client, never()).createSession(any()); + verify(client, never()).loadSession(any()); + verify(client, never()).settleLifecycle(any(), any()); + } + + @ParameterizedTest + @ValueSource(ints = {404, 409, 503}) + void successorOnlyIgnoresAnAbsentAttachmentDuringLifecycleDetach(int status) { + HostedHarnessClient client = mock(HostedHarnessClient.class); + OperationRecord operation = mock(OperationRecord.class); + when(operation.tenantId()).thenReturn("tenant-a"); + when(operation.sessionId()).thenReturn(SESSION_ID); + when(operation.operationId()).thenReturn("delete-1"); + when(operation.claimGeneration()).thenReturn(2L); + DaemonHttpException failure = mock(DaemonHttpException.class); + when(failure.getStatusCode()).thenReturn(status); + doThrow(failure).when(client).detachLifecycle(SESSION_ID, + Map.of("operationId", "delete-1", "claimGeneration", 2L)); + QwenHostedHarnessConnector successor = connector(client); + if (status == 404) { + assertThatCode(() -> successor.detachLifecycle(operation)).doesNotThrowAnyException(); + } else { + assertThatThrownBy(() -> successor.detachLifecycle(operation)).isSameAs(failure); + } + } + @ParameterizedTest @ValueSource(strings = {"hosted-workspace-files/1", "hosted-workspace-files/2"}) void boundCreateConflictLoadsOriginalWorkspaceAndProfileAndRechecksCachedGrant(String profile) { diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java index 849dc2b6e53..62fd8c9e298 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.alibaba.qwen.code.managedagent.api.ApiException; import com.alibaba.qwen.code.managedagent.api.WorkspaceSelection; import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationKind; @@ -15,19 +16,103 @@ import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.time.Clock; import java.time.Duration; +import java.util.Base64; +import java.util.HexFormat; import java.util.List; import java.util.UUID; import org.flywaydb.core.Flyway; import org.h2.jdbcx.JdbcDataSource; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.http.HttpStatus; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.transaction.support.TransactionTemplate; class WorkspaceLifecycleStoreTest { + @ParameterizedTest + @ValueSource(strings = {"{", "[]", "null", "{\"n\":1e999}", "{\"a\":1,\"a\":2}"}) + void lifecycleJournalValidationRejectsInvalidRecordsAndRollsBack(String line) throws Exception { + for (String mode : List.of("ordinary", "authority", "settlement")) { + var fixture = fixture(); + var journal = new ManagedSessionStore(fixture.jdbc); + var writer = fixture.transactions.execute(ignored -> journal.acquireWriter("tenant", fixture.session, + "w".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "original", 60_000L))); + var operation = "ordinary".equals(mode) ? null : fixture.admit(OperationKind.DELETE); + byte[] body = "{}".getBytes(StandardCharsets.UTF_8); + var resource = new ManagedSessionStoreModels.CommitResource("candidate", "managed-message", 1, body.length, + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(body)), Base64.getEncoder().encodeToString(body)); + byte[] bytes = (line + "\n{}\n").getBytes(StandardCharsets.UTF_8); + var request = new ManagedSessionStoreModels.CommitTransactionRequest("workspace", "original", writer.writerGeneration(), + 0, 0, "transaction", "session.create", "command", "a".repeat(64), 0, 0, 0, null, null, null, 0, null, 2, + Base64.getEncoder().encodeToString(bytes), HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)), + List.of(resource)); + assertThatThrownBy(() -> fixture.transactions.execute(ignored -> journal.commit("tenant", fixture.session, "w".repeat(32), + request, "authority".equals(mode) ? WorkspaceLifecycleStore.authority(operation) : null))) + .isInstanceOfSatisfying(ApiException.class, error -> { + assertThat(error.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(error.getCode()).isEqualTo(ManagedSessionStoreModels.ERROR_INVALID_REQUEST); + }); + assertThat(fixture.jdbc.queryForObject("SELECT journal_revision FROM qwen_managed_session_journal_head", Long.class)).isZero(); + assertThat(fixture.jdbc.queryForObject("SELECT COUNT(*) FROM qwen_managed_session_journal_tx", Integer.class)).isZero(); + assertThat(fixture.jdbc.queryForObject("SELECT COUNT(*) FROM qwen_managed_session_resource", Integer.class)).isZero(); + } + } + + @Test + void detachOnlyAuthorizationAcceptsOriginalExpiredOrSealedWriterAfterEffects() throws Exception { + var fixture = fixture(); + var journal = new ManagedSessionStore(fixture.jdbc); + var writer = fixture.transactions.execute(ignored -> journal.acquireWriter("tenant", fixture.session, + "w".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "original", 60_000L))); + byte[] definition = "{\"toolProfile\":\"hosted-workspace-files/1\"}".getBytes(StandardCharsets.UTF_8); + String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(definition)); + var header = new ObjectMapper().createObjectNode().put("subtype", "managed_session_header_v1"); + header.putObject("managedSession").putObject("definitionRef").put("resourceId", "definition") + .put("kind", "managed-session-definition").put("schemaVersion", 1).put("byteLength", definition.length).put("digest", digest); + byte[] bytes = (header + "\n{}\n").getBytes(StandardCharsets.UTF_8); + var commit = new ManagedSessionStoreModels.CommitTransactionRequest("workspace", "original", writer.writerGeneration(), + 0, 0, "transaction", "session.create", "command", "a".repeat(64), 0, 0, 0, null, null, null, 0, null, 2, + Base64.getEncoder().encodeToString(bytes), HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)), + List.of(new ManagedSessionStoreModels.CommitResource("definition", "managed-session-definition", 1, + definition.length, digest, Base64.getEncoder().encodeToString(definition)))); + fixture.transactions.execute(ignored -> journal.commit("tenant", fixture.session, "w".repeat(32), commit)); + var operation = fixture.admit(OperationKind.DELETE); + var authority = WorkspaceLifecycleStore.authority(operation); + var cleanup = new ManagedSessionStoreModels.AuthorizeLifecycleRequest("workspace", "original", writer.writerGeneration()); + assertThatThrownBy(() -> fixture.transactions.executeWithoutResult(ignored -> journal.authorizeLifecycle( + "tenant", fixture.session, "w".repeat(32), cleanup, authority))).hasMessageContaining("blocked"); + assertThat(fixture.transactions.execute(ignored -> fixture.lifecycle.recoverEffects(operation))) + .isNotNull(); + fixture.jdbc.update("UPDATE qwen_managed_session_journal_head SET writer_lease_until = ?", new java.sql.Timestamp(0)); + assertThat(journal.hasLiveWriter("tenant", fixture.session)).isFalse(); + fixture.transactions.executeWithoutResult(ignored -> journal.authorizeLifecycle("tenant", fixture.session, "w".repeat(32), cleanup, authority)); + var dispatch = new ManagedSessionStoreModels.AuthorizeLifecycleRequest("workspace", "original", writer.writerGeneration(), "delete"); + assertThatThrownBy(() -> fixture.transactions.executeWithoutResult(ignored -> journal.authorizeLifecycle( + "tenant", fixture.session, "w".repeat(32), dispatch, authority))).hasMessageContaining("writer"); + assertThatThrownBy(() -> fixture.transactions.executeWithoutResult(ignored -> journal.renewWriter("tenant", fixture.session, "w".repeat(32), + new ManagedSessionStoreModels.RenewWriterRequest("workspace", "original", writer.writerGeneration(), 60_000L), authority))) + .hasMessageContaining("writer"); + assertThatThrownBy(() -> fixture.transactions.executeWithoutResult(ignored -> journal.authorizeLifecycle( + "tenant", fixture.session, "x".repeat(32), cleanup, authority))).hasMessageContaining("writer"); + assertThatThrownBy(() -> fixture.transactions.executeWithoutResult(ignored -> journal.authorizeLifecycle("tenant", fixture.session, + "w".repeat(32), new ManagedSessionStoreModels.AuthorizeLifecycleRequest("workspace", "original", writer.writerGeneration() + 1), + authority))).hasMessageContaining("writer"); + fixture.transactions.executeWithoutResult(ignored -> journal.sealWriter("tenant", fixture.session, "w".repeat(32), + new ManagedSessionStoreModels.SealWriterRequest("workspace", "original", writer.writerGeneration()), authority)); + fixture.transactions.executeWithoutResult(ignored -> journal.authorizeLifecycle("tenant", fixture.session, "w".repeat(32), cleanup, authority)); + assertThatThrownBy(() -> fixture.transactions.executeWithoutResult(ignored -> journal.authorizeLifecycle( + "tenant", fixture.session, "w".repeat(32), dispatch, authority))).hasMessageContaining("writer"); + fixture.jdbc.update("UPDATE managed_agent_operation SET lease_until = 0"); + assertThatThrownBy(() -> fixture.transactions.executeWithoutResult(ignored -> journal.authorizeLifecycle( + "tenant", fixture.session, "w".repeat(32), cleanup, authority))).hasMessageContaining("blocked"); + } + @Test void legacyCloseKeepsItsOriginalHookPathButCannotAdmitOrdinaryWork() { var fixture = fixture(); diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java index 14beff40755..ce82f5852d5 100644 --- a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java +++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java @@ -546,6 +546,19 @@ public void detachLifecycle(HarnessSessionRef session, Map autho removeAttachment(ref); } + public void detachLifecycle(String harnessSessionId, Map authority) { + ensureOpen(); + String sessionId = requireUuid(harnessSessionId, "harnessSessionId"); + HttpSupport.Response response = sendMutation(sessionPath(sessionId) + "/detach", + Map.of("authority", authority), null, "POST /session/:id/detach"); + requireMutationStatus(response, 204, "POST /session/:id/detach"); + AttachmentState state = attachments.remove(sessionId); + if (state != null) { + state.cancel(); + } + activePrompts.remove(sessionId); + } + public void detachSession(HarnessSessionRef session) { HarnessSessionRef ref = requireSessionRef(session); HttpSupport.Response response = sendMutation( diff --git a/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java index d692b9deed9..68ae99c244a 100644 --- a/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java +++ b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java @@ -102,6 +102,31 @@ void lifecycleSettlementRetainsAttachmentAndDetachCarriesTheNewClaim() { } } + @Test + void successorLifecycleDetachNeedsNoNewAttachmentOrLifecycleDispatch() { + AtomicReference> detach = new AtomicReference<>(); + server.createContext("/session/" + SESSION_ID + "/detach", exchange -> { + assertEquals("POST", exchange.getRequestMethod()); + detach.set(JsonSupport.parseObject(readBody(exchange), "detach")); + assertPrivateHeaders(exchange, false); + exchange.getResponseHeaders().set(HostedHarnessClient.BOOT_ID_HEADER, BOOT_ID); + exchange.getResponseHeaders().set("Connection", "close"); + exchange.sendResponseHeaders(204, -1); + exchange.close(); + }); + AtomicInteger otherMutations = new AtomicInteger(); + server.createContext("/session", exchange -> { + otherMutations.incrementAndGet(); + sendSessionJson(exchange, 500, "{}"); + }); + try (HostedHarnessClient client = newClient()) { + var authority = Map.of("operationId", "delete-1", "claimGeneration", 2); + client.detachLifecycle(SESSION_ID, authority); + assertEquals(Map.of("authority", authority), detach.get()); + assertEquals(0, otherMutations.get()); + } + } + @Test void legacyHarnessCannotFallBackToDeleteForLifecycleSettlement() { AtomicInteger requests = new AtomicInteger(); From 841b5013a20cdfe1556fe594398a7426e8579136 Mon Sep 17 00:00:00 2001 From: jinye Date: Mon, 5 Oct 2026 00:05:09 +0800 Subject: [PATCH 07/17] fix(managed-agent): address PR CI fixture failures (#13354) --- ...hosted-harness-session.issue-13328.test.ts | 1 + .../store/WorkspaceLifecycleStoreTest.java | 59 ++++++++++--------- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/packages/cli/src/serve/hosted-harness-session.issue-13328.test.ts b/packages/cli/src/serve/hosted-harness-session.issue-13328.test.ts index 6ecab348c02..8e5f295527d 100644 --- a/packages/cli/src/serve/hosted-harness-session.issue-13328.test.ts +++ b/packages/cli/src/serve/hosted-harness-session.issue-13328.test.ts @@ -82,6 +82,7 @@ vi.mock( resourceStore, toolResultResources: resourceStore, assertWritable: state.assertWritable, + authorizeOrdinary: async () => undefined, publication: { owner: async () => ({ writerId: BOOT_ID, writerGeneration: 1 }), request: (route: string, body: unknown, token?: string) => diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java index 62fd8c9e298..e401ae03267 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java @@ -23,11 +23,13 @@ import java.util.HexFormat; import java.util.List; import java.util.UUID; +import java.util.stream.Stream; import org.flywaydb.core.Flyway; import org.h2.jdbcx.JdbcDataSource; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.springframework.http.HttpStatus; import org.springframework.jdbc.core.JdbcTemplate; @@ -36,32 +38,35 @@ class WorkspaceLifecycleStoreTest { @ParameterizedTest - @ValueSource(strings = {"{", "[]", "null", "{\"n\":1e999}", "{\"a\":1,\"a\":2}"}) - void lifecycleJournalValidationRejectsInvalidRecordsAndRollsBack(String line) throws Exception { - for (String mode : List.of("ordinary", "authority", "settlement")) { - var fixture = fixture(); - var journal = new ManagedSessionStore(fixture.jdbc); - var writer = fixture.transactions.execute(ignored -> journal.acquireWriter("tenant", fixture.session, - "w".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "original", 60_000L))); - var operation = "ordinary".equals(mode) ? null : fixture.admit(OperationKind.DELETE); - byte[] body = "{}".getBytes(StandardCharsets.UTF_8); - var resource = new ManagedSessionStoreModels.CommitResource("candidate", "managed-message", 1, body.length, - HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(body)), Base64.getEncoder().encodeToString(body)); - byte[] bytes = (line + "\n{}\n").getBytes(StandardCharsets.UTF_8); - var request = new ManagedSessionStoreModels.CommitTransactionRequest("workspace", "original", writer.writerGeneration(), - 0, 0, "transaction", "session.create", "command", "a".repeat(64), 0, 0, 0, null, null, null, 0, null, 2, - Base64.getEncoder().encodeToString(bytes), HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)), - List.of(resource)); - assertThatThrownBy(() -> fixture.transactions.execute(ignored -> journal.commit("tenant", fixture.session, "w".repeat(32), - request, "authority".equals(mode) ? WorkspaceLifecycleStore.authority(operation) : null))) - .isInstanceOfSatisfying(ApiException.class, error -> { - assertThat(error.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(error.getCode()).isEqualTo(ManagedSessionStoreModels.ERROR_INVALID_REQUEST); - }); - assertThat(fixture.jdbc.queryForObject("SELECT journal_revision FROM qwen_managed_session_journal_head", Long.class)).isZero(); - assertThat(fixture.jdbc.queryForObject("SELECT COUNT(*) FROM qwen_managed_session_journal_tx", Integer.class)).isZero(); - assertThat(fixture.jdbc.queryForObject("SELECT COUNT(*) FROM qwen_managed_session_resource", Integer.class)).isZero(); - } + @MethodSource("invalidJournalRecords") + void lifecycleJournalValidationRejectsInvalidRecordsAndRollsBack(String line, String mode) throws Exception { + var fixture = fixture(); + var journal = new ManagedSessionStore(fixture.jdbc); + var writer = fixture.transactions.execute(ignored -> journal.acquireWriter("tenant", fixture.session, + "w".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "original", 60_000L))); + var operation = "ordinary".equals(mode) ? null : fixture.admit(OperationKind.DELETE); + byte[] body = "{}".getBytes(StandardCharsets.UTF_8); + var resource = new ManagedSessionStoreModels.CommitResource("candidate", "managed-message", 1, body.length, + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(body)), Base64.getEncoder().encodeToString(body)); + byte[] bytes = (line + "\n{}\n").getBytes(StandardCharsets.UTF_8); + var request = new ManagedSessionStoreModels.CommitTransactionRequest("workspace", "original", writer.writerGeneration(), + 0, 0, "transaction", "session.create", "command", "a".repeat(64), 0, 0, 0, null, null, null, 0, null, 2, + Base64.getEncoder().encodeToString(bytes), HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)), + List.of(resource)); + assertThatThrownBy(() -> fixture.transactions.execute(ignored -> journal.commit("tenant", fixture.session, "w".repeat(32), + request, "authority".equals(mode) ? WorkspaceLifecycleStore.authority(operation) : null))) + .isInstanceOfSatisfying(ApiException.class, error -> { + assertThat(error.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(error.getCode()).isEqualTo(ManagedSessionStoreModels.ERROR_INVALID_REQUEST); + }); + assertThat(fixture.jdbc.queryForObject("SELECT journal_revision FROM qwen_managed_session_journal_head", Long.class)).isZero(); + assertThat(fixture.jdbc.queryForObject("SELECT COUNT(*) FROM qwen_managed_session_journal_tx", Integer.class)).isZero(); + assertThat(fixture.jdbc.queryForObject("SELECT COUNT(*) FROM qwen_managed_session_resource", Integer.class)).isZero(); + } + + static Stream invalidJournalRecords() { + return Stream.of("{", "[]", "null", "{\"n\":1e999}", "{\"a\":1,\"a\":2}") + .flatMap(line -> Stream.of("ordinary", "authority", "settlement").map(mode -> Arguments.of(line, mode))); } @Test From 8356fb7d1b8d164f2bb9895b0964af5e2b079c4a Mon Sep 17 00:00:00 2001 From: jinye Date: Mon, 5 Oct 2026 05:15:40 +0800 Subject: [PATCH 08/17] codex: address PR review feedback (#13354) --- .../workspace-session-active-delete-l3.md | 5 + ...orkspace-session-active-delete-l3.zh-CN.md | 4 + .../store/WorkspaceLifecycleStore.java | 7 +- .../store/WorkspaceLifecycleMySqlIT.java | 61 +++++++++++++ .../store/WorkspaceLifecycleStoreTest.java | 91 ++++++++++++++++++- .../runtimebroker/RuntimeBrokerService.java | 3 + .../RuntimeBrokerServiceTest.java | 52 +++++++++++ 7 files changed, 220 insertions(+), 3 deletions(-) diff --git a/docs/design/workspace-session-active-delete-l3.md b/docs/design/workspace-session-active-delete-l3.md index 8b9fe10c9e3..fea83211ad2 100644 --- a/docs/design/workspace-session-active-delete-l3.md +++ b/docs/design/workspace-session-active-delete-l3.md @@ -96,6 +96,9 @@ Hooks; a settled plan marker alone is insufficient. Cold load restores saved sta Turn or startup Hook. Reuse a verifiable original Runtime; a replacement generation never replays effects. First initialization is permitted only when no Runtime has ever been created and authorization is valid. +Plan identities use fixed compact JSON bytes, independent of application JSON +formatting. Recovery rejects pending, failed or cancelled local Session routes +without waiting for ordinary acquisition or propagating its outcome. Before each new side-effect dispatch, check current ACL, mount and identity. After revocation, lookup and settlement of already dispatched work continue; @@ -164,6 +167,8 @@ original protocol and evidence, without new Hook identities. Live protocol-zero close attachments retain their legacy DELETE and original Hook control path only while their persisted close claim is valid. Ordinary execution remains fenced; the exception cannot authorize L3 or MCP execution. +Legacy claim validity compares database epoch milliseconds on both sides, +independent of JVM, JDBC and database session time zones. Upgrade all coordinators and Harnesses before enabling new L3 admission. A missing protocol capability rejects admission rather than falling back to legacy DELETE. Do not diff --git a/docs/design/workspace-session-active-delete-l3.zh-CN.md b/docs/design/workspace-session-active-delete-l3.zh-CN.md index 1becd2ee545..0ccb0e642d7 100644 --- a/docs/design/workspace-session-active-delete-l3.zh-CN.md +++ b/docs/design/workspace-session-active-delete-l3.zh-CN.md @@ -75,6 +75,9 @@ dispatch intent 和结果。SessionEnd 的全部子执行提交结果后才开 包括异步 Hook;只有 plan marker 结算不足。冷加载只恢复 保存状态,不创建用户 Turn 或 startup Hook。复用可核验的原 Runtime,不用替代 代际重放副作用。仅从未创建 Runtime 且授权有效时允许首次初始化。 +Plan 身份使用固定的紧凑 JSON 字节,不受应用 JSON 格式化配置影响。 +恢复遇到在途、失败或已取消的本地 Session 路由时直接拒绝, +不等待普通 acquire,也不传播其结果。 每次新副作用派发前核对当前 ACL、挂载和身份。撤销后仍可查询、结算已派发工作; 未派发 Hook 保持 recovery_blocked,直至恢复权限。unknown 保留所有权,不能 @@ -126,6 +129,7 @@ L3 返回 `workspace_lifecycle_journal_unverified`。未来 compaction 必须 升级前已接纳的操作沿用原协议与证据,不产生新 Hook 身份。 仍存活的 protocol-zero close attachment 仅在持久 close claim 有效时保留原 DELETE 和原 Hook control 路径。普通执行继续被围栏阻止;此例外不能授权 L3 或 MCP 执行。 +旧 claim 的有效性统一比较数据库 epoch 毫秒,不受 JVM、JDBC 与数据库会话时区影响。 启用 L3 准入前升级全部 coordinator 和 Harness。缺少新协议能力时拒绝准入, 不回退到旧 DELETE。存在未完成 L3 操作时不回滚到旧 coordinator。 L2 CLOSED/ARCHIVED 删除继续独立于 Harness 可用性。 diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStore.java index 1f81cb7be8c..381d85c38de 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStore.java @@ -21,8 +21,11 @@ @Repository public class WorkspaceLifecycleStore { + private static final ObjectMapper OCCURRENCE_JSON = new ObjectMapper(); + public static boolean legacyClose(JdbcTemplate jdbc, String tenant, String session) { - long now = jdbc.queryForObject("SELECT CURRENT_TIMESTAMP(6)", java.sql.Timestamp.class).getTime(); + long now = jdbc.queryForObject("SELECT UNIX_TIMESTAMP(), EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(6))", + (row, index) -> row.getLong(1) * 1000 + row.getLong(2) / 1000); return !jdbc.queryForList("SELECT o.operation_id FROM managed_agent_operation o" + " JOIN managed_agent_session s ON s.tenant_id = o.tenant_id AND s.session_id = o.session_id" + " JOIN qwen_runtime_harness_drain f ON f.tenant_id = o.tenant_id AND f.harness_session_id = o.session_id" @@ -341,7 +344,7 @@ private List events(OperationRecord operation) { private String occurrence(String event, String operationId) { try { return "hook-plan-" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest( - json.writeValueAsBytes(List.of(event, operationId)))); + OCCURRENCE_JSON.writeValueAsBytes(List.of(event, operationId)))); } catch (java.io.IOException | NoSuchAlgorithmException error) { throw new IllegalStateException(error); } diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleMySqlIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleMySqlIT.java index b19dc0fb682..a02d6bf6dac 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleMySqlIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleMySqlIT.java @@ -8,15 +8,26 @@ import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.AcquireWriterRequest; import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.AuthorizeLifecycleRequest; import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationKind; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.TimeZone; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.stream.Stream; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.jdbc.datasource.DriverManagerDataSource; class WorkspaceLifecycleMySqlIT extends WorkspaceLifecycleStoreTest { @@ -50,6 +61,56 @@ Fixture fixture() { return new Fixture(source); } + @ParameterizedTest + @MethodSource("legacyCloseClockZones") + @ResourceLock("java.util.TimeZone") + void legacyCloseUsesTheDatabaseEpochAcrossTimeZones(String jvmZone, String databaseZone, String connectionZone) { + TimeZone previous = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone(jvmZone)); + String url = source.getUrl(); + source.setUrl(url + (url.contains("?") ? "&" : "?") + + "connectionTimeZone=" + URLEncoder.encode(connectionZone, StandardCharsets.UTF_8) + + "&sessionVariables=" + URLEncoder.encode("time_zone='" + databaseZone + "'", StandardCharsets.UTF_8)); + var fixture = fixture(); + var journal = new ManagedSessionStore(fixture.jdbc); + var writer = fixture.transactions.execute(ignored -> journal.acquireWriter("tenant", fixture.session, + "w".repeat(32), new AcquireWriterRequest("workspace", "original", 60_000L))); + String id = fixture.transactions.execute(ignored -> fixture.store.beginWorkspaceLifecycle("tenant", fixture.session, + OperationKind.CLOSE, "owner", "a".repeat(64), "legacy", "digest", true).operation().operationId()); + var operation = fixture.transactions.execute(ignored -> fixture.store.claimOperation("tenant", fixture.session, + id, "worker", Duration.ofMinutes(1)).orElseThrow()); + fixture.bindings.requestHarnessDrain("tenant", fixture.session); + var legacy = new AuthorizeLifecycleRequest("workspace", "original", writer.writerGeneration(), "legacy-close"); + var ordinary = new AuthorizeLifecycleRequest("workspace", "original", writer.writerGeneration()); + var execution = new WorkspaceExecutionStore(fixture.jdbc, new DataSourceTransactionManager(source)); + assertThat(operation.lifecycleProtocolVersion()).isZero(); + assertThat(fixture.transactions.execute(ignored -> WorkspaceLifecycleStore.legacyClose(fixture.jdbc, "tenant", fixture.session))) + .isTrue(); + fixture.transactions.executeWithoutResult(ignored -> journal.authorizeOrdinary("tenant", fixture.session, "w".repeat(32), legacy)); + execution.authorizeLegacyClose(fixture.store.requireSession("tenant", fixture.session)); + assertThatThrownBy(() -> fixture.transactions.executeWithoutResult(ignored -> journal.authorizeOrdinary( + "tenant", fixture.session, "w".repeat(32), ordinary))).isInstanceOfSatisfying(ApiException.class, + error -> assertThat(error.getCode()).isEqualTo("managed_session_lifecycle_active")); + fixture.jdbc.update("UPDATE managed_agent_operation SET lease_until = UNIX_TIMESTAMP() * 1000 - 1000 WHERE operation_id = ?", id); + assertThat(fixture.transactions.execute(ignored -> WorkspaceLifecycleStore.legacyClose(fixture.jdbc, "tenant", fixture.session))) + .isFalse(); + assertThatThrownBy(() -> fixture.transactions.executeWithoutResult(ignored -> journal.authorizeOrdinary( + "tenant", fixture.session, "w".repeat(32), legacy))).isInstanceOfSatisfying(ApiException.class, + error -> assertThat(error.getCode()).isEqualTo("managed_session_lifecycle_active")); + assertThatThrownBy(() -> execution.authorizeLegacyClose(fixture.store.requireSession("tenant", fixture.session))) + .hasMessageContaining("unavailable"); + } finally { + TimeZone.setDefault(previous); + } + } + + static Stream legacyCloseClockZones() { + return Stream.of(new String[]{"Asia/Shanghai", "+00:00"}, new String[]{"UTC", "+08:00"}) + .flatMap(zones -> List.of("LOCAL", "UTC", "+08:00", "Asia/Shanghai").stream() + .map(connection -> Arguments.of(zones[0], zones[1], connection))); + } + @Test void admissionSerializesAnOrdinaryWriterOnThePlacementFence() throws Exception { var fixture = fixture(); diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java index e401ae03267..caef9505474 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java @@ -14,7 +14,10 @@ import com.alibaba.qwen.code.runtimebroker.RuntimeProvisionRequest; import com.alibaba.qwen.code.runtimebroker.RuntimeScope; import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ObjectNode; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.Clock; @@ -69,6 +72,82 @@ static Stream invalidJournalRecords() { .flatMap(line -> Stream.of("ordinary", "authority", "settlement").map(mode -> Arguments.of(line, mode))); } + @ParameterizedTest + @MethodSource("hookReceiptPaths") + void hookReceiptsUseCompactProtocolIdentitiesWithAnIndentedApplicationMapper(OperationKind kind, boolean recover) throws Exception { + var fixture = fixture(); + fixture.json.enable(SerializationFeature.INDENT_OUTPUT); + var journal = new ManagedSessionStore(fixture.jdbc); + var writer = fixture.transactions.execute(ignored -> journal.acquireWriter("tenant", fixture.session, + "w".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "original", 60_000L))); + byte[] definition = "{\"toolProfile\":\"hosted-workspace-files/1\",\"hookCatalog\":{}}".getBytes(StandardCharsets.UTF_8); + String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(definition)); + var header = fixture.json.createObjectNode().put("subtype", "managed_session_header_v1"); + header.putObject("managedSession").putObject("definitionRef").put("resourceId", "definition") + .put("kind", "managed-session-definition").put("schemaVersion", 1).put("byteLength", definition.length).put("digest", digest); + byte[] bytes = (header + "\n{}\n").getBytes(StandardCharsets.UTF_8); + var commit = new ManagedSessionStoreModels.CommitTransactionRequest("workspace", "original", writer.writerGeneration(), + 0, 0, "transaction", "session.create", "command", "a".repeat(64), 0, 0, 0, null, null, null, 0, null, 2, + Base64.getEncoder().encodeToString(bytes), HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)), + List.of(new ManagedSessionStoreModels.CommitResource("definition", "managed-session-definition", 1, + definition.length, digest, Base64.getEncoder().encodeToString(definition)))); + fixture.transactions.execute(ignored -> journal.commit("tenant", fixture.session, "w".repeat(32), commit)); + var operation = fixture.admit(kind); + var receipt = fixture.json.createObjectNode().put("protocolVersion", 1).put("operationId", operation.operationId()) + .put("kind", kind.name().toLowerCase(java.util.Locale.ROOT)); + receipt.putObject("sessionKey").put("tenantId", "tenant").put("workspaceId", "workspace").put("sessionId", fixture.session); + receipt.set("definitionRef", header.path("managedSession").path("definitionRef")); + var effects = receipt.putArray("effects"); + for (String event : kind == OperationKind.CLOSE ? List.of("SessionEnd") : List.of("SessionEnd", "SessionDelete")) { + // These bytes are JSON.stringify([event, operationId]) on the Harness wire. + String id = "hook-plan-" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest( + ("[\"" + event + "\",\"" + operation.operationId() + "\"]").getBytes(StandardCharsets.UTF_8))); + var plan = fixture.json.createObjectNode(); + plan.putObject("input").put("hook_event_name", event).put("session_id", fixture.session); + var planRef = fixture.resource(event + "-plan", "hook-data", plan); + var resultRef = fixture.resource(event + "-result", "hook-data", fixture.json.createObjectNode()); + var record = fixture.json.createObjectNode().put("hookExecutionId", id).put("occurrenceId", operation.operationId()) + .put("runtimeSessionId", "original").put("registrationId", "registration").put("eventName", event) + .put("ordinal", 0).put("hookId", "__plan__").put("cancelRequested", false); + record.putNull("onceKey"); + record.set("planRef", planRef); + record.set("inputRef", planRef); + record.set("resultRef", resultRef); + var run = record.putObject("run").put("state", "settled").put("effectId", id).put("execution", "settled"); + run.putObject("definition").put("definitionId", "catalog").put("definitionRevision", 1).put("definitionDigest", "b".repeat(64)); + for (String key : List.of("reason", "executionCallId", "dispatchId", "deliveryId", "runtime", "delivery")) { + run.putNull(key); + } + ManagedHookRecords.requireExecution(record); + var ref = fixture.resource(event + "-record", "hook-execution", record); + fixture.jdbc.update("INSERT INTO qwen_managed_session_extension_record (session_scope_key, record_key, tenant_id, workspace_id," + + " session_id, domain, record_id, operation_hash, revision, record_resource_id, task_kind, task_state, created_at, first_sequence)" + + " VALUES (?, ?, 'tenant', 'workspace', ?, 'hook_execution', ?, ?, 1, ?, 'hook', 'settled', 1, 1)", + ManagedSessionStore.sessionScopeKey("tenant", fixture.session), + ManagedExtensionProjection.recordKey(fixture.session, "hook_execution", id), + fixture.session, id, "a".repeat(64), ref.path("resourceId").asText()); + effects.addObject().put("event", event).set("recordRef", ref); + } + if (recover) { + JsonNode recovered = fixture.transactions.execute(ignored -> fixture.lifecycle.recoverEffects(operation)); + assertThat(recovered).isNotNull(); + assertThat(fixture.json.readTree(recovered.toString())).isEqualTo(receipt); + } else { + fixture.transactions.executeWithoutResult(ignored -> fixture.lifecycle.saveEffects(operation, receipt)); + } + assertThat(fixture.json.readTree(fixture.jdbc.queryForObject("SELECT lifecycle_effects_receipt_json FROM managed_agent_operation", + String.class))).isEqualTo(receipt); + assertThat(fixture.jdbc.queryForObject("SELECT phase FROM qwen_runtime_harness_drain", String.class)).isEqualTo("DRAINING"); + fixture.transactions.executeWithoutResult(ignored -> journal.sealWriter("tenant", fixture.session, "w".repeat(32), + new ManagedSessionStoreModels.SealWriterRequest("workspace", "original", writer.writerGeneration()), + WorkspaceLifecycleStore.authority(operation))); + fixture.transactions.executeWithoutResult(ignored -> fixture.lifecycle.verifyCompletion(operation)); + } + + static Stream hookReceiptPaths() { + return Stream.of(OperationKind.CLOSE, OperationKind.DELETE).flatMap(kind -> Stream.of(false, true).map(recover -> Arguments.of(kind, recover))); + } + @Test void detachOnlyAuthorizationAcceptsOriginalExpiredOrSealedWriterAfterEffects() throws Exception { var fixture = fixture(); @@ -260,6 +339,7 @@ Fixture fixture() { static final class Fixture { final JdbcTemplate jdbc; + final ObjectMapper json = new ObjectMapper(); final TransactionTemplate transactions; final ManagedAgentStore store; final WorkspaceLifecycleStore lifecycle; @@ -280,7 +360,6 @@ private static javax.sql.DataSource h2() { Flyway.configure().dataSource(source).locations("classpath:db/migration").load().migrate(); jdbc = new JdbcTemplate(source); transactions = new TransactionTemplate(new DataSourceTransactionManager(source)); - var json = new ObjectMapper(); var properties = new ManagedAgentProperties(); properties.getHarness().setWorkspaceFilesEnabled(true); store = new ManagedAgentStore(jdbc, json, Clock.systemUTC(), ignored -> {}, new ManagedWorkspaceRegistry(jdbc), properties); @@ -298,6 +377,16 @@ private static javax.sql.DataSource h2() { null, null, List.of(), null, new WorkspaceSelection("workspace", ".")).sessionId()); } + ObjectNode resource(String id, String kind, JsonNode body) throws Exception { + byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8); + String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + jdbc.update("INSERT INTO qwen_managed_session_resource (session_scope_key, tenant_id, workspace_id, session_id, resource_id, kind," + + " schema_version, byte_length, sha256, storage_kind, inline_bytes, publish_command_id, state, created_at)" + + " VALUES (?, 'tenant', 'workspace', ?, ?, ?, 1, ?, ?, 'MYSQL_INLINE', ?, 'committed-hooks', 'REFERENCED', CURRENT_TIMESTAMP(6))", + ManagedSessionStore.sessionScopeKey("tenant", session), session, id, kind, bytes.length, digest, bytes); + return json.createObjectNode().put("resourceId", id).put("kind", kind).put("schemaVersion", 1).put("byteLength", bytes.length).put("digest", digest); + } + RuntimeScope scope() { return new RuntimeScope("tenant", "workspace", "1", "/workspace", "digest", "session"); } diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerService.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerService.java index 96ffa41bc42..f5217b03c35 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerService.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerService.java @@ -443,6 +443,9 @@ public CompletionStage acquireRecovery(String harnessSessi var context = CompletableFuture.completedFuture(new SessionContext(saved.getSession(), record, live.lease())); var existing = sessions.putIfAbsent(runtimeSessionId, context); if (existing != null) { + if (!existing.isDone() || existing.isCompletedExceptionally()) { + throw conflict("workspace_close_identity_unverified", "Original Hook Session is not attested in this Broker"); + } SessionContext current = existing.join(); requireSameSession(current.session(), saved.getSession()); if (!current.binding().sameIdentity(original)) { diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerServiceTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerServiceTest.java index a860dfece67..024d4510a56 100644 --- a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerServiceTest.java +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerServiceTest.java @@ -99,6 +99,58 @@ void hookRecoveryUsesTheOriginalBindingAndNeverAcquiresAReplacement() { } } + @Test + void hookRecoveryDoesNotWaitForAnOrdinaryAcquireOfTheSameRuntimeId() throws Exception { + try (Fixture fixture = new Fixture(SESSION_SCOPE)) { + RuntimeSessionRecord original = join(fixture.service.acquire("harness-a", "runtime-a", "bootstrap")); + var field = RuntimeBrokerService.class.getDeclaredField("sessions"); + field.setAccessible(true); + ((Map) field.get(fixture.service)).remove("runtime-a"); + fixture.resolver.result = CompletableFuture.completedFuture(new RuntimeScope( + "tenant", "other-workspace", "generation", "/other", "other-capability", "session")); + var pending = new CompletableFuture(); + fixture.transport.acquireResult = pending; + var ordinary = fixture.service.acquire("harness-b", "runtime-a", "bootstrap"); + int provisions = fixture.provisioner.calls.get(); + int acquires = fixture.transport.acquireCalls.get(); + try { + assertTimeoutPreemptively(Duration.ofSeconds(1), () -> assertEquals("workspace_close_identity_unverified", + failure(fixture.service.acquireRecovery("harness-a", "runtime-a", + original.getBindingId(), original.getRuntimeGeneration())).getCode())); + assertFalse(ordinary.toCompletableFuture().isDone()); + assertSame(original, fixture.sessionRepository.findById(SESSION_SCOPE, "runtime-a")); + assertEquals(provisions, fixture.provisioner.calls.get()); + assertEquals(acquires, fixture.transport.acquireCalls.get()); + } finally { + pending.completeExceptionally(new RuntimeBrokerException(503, "ordinary_failure", "failed", true)); + } + } + } + + @Test + @SuppressWarnings("unchecked") + void hookRecoveryRejectsFailedAndCancelledLocalRoutes() throws Exception { + try (Fixture fixture = new Fixture(SESSION_SCOPE)) { + RuntimeSessionRecord original = join(fixture.service.acquire("harness-a", "runtime-a", "bootstrap")); + var field = RuntimeBrokerService.class.getDeclaredField("sessions"); + field.setAccessible(true); + var routes = (Map>) field.get(fixture.service); + var cancelled = new CompletableFuture<>(); + cancelled.cancel(false); + int provisions = fixture.provisioner.calls.get(); + int acquires = fixture.transport.acquireCalls.get(); + for (var route : List.of(CompletableFuture.failedFuture( + new RuntimeBrokerException(503, "ordinary_failure", "failed", true)), cancelled)) { + routes.put("runtime-a", route); + assertEquals("workspace_close_identity_unverified", failure(fixture.service.acquireRecovery( + "harness-a", "runtime-a", original.getBindingId(), original.getRuntimeGeneration())).getCode()); + } + assertSame(original, fixture.sessionRepository.findById(SESSION_SCOPE, "runtime-a")); + assertEquals(provisions, fixture.provisioner.calls.get()); + assertEquals(acquires, fixture.transport.acquireCalls.get()); + } + } + @Test void concurrentAcquireOfOneSessionCallsRuntimeOnce() { try (Fixture fixture = new Fixture(WORKSPACE_SCOPE)) { From 3cabc9f7e662eb6e921ab59bebbc8a7743cd0fc2 Mon Sep 17 00:00:00 2001 From: jinye Date: Mon, 5 Oct 2026 12:37:16 +0800 Subject: [PATCH 09/17] codex: address PR review feedback (#13354) --- .../workspace-session-active-delete-l3.md | 15 +- ...orkspace-session-active-delete-l3.zh-CN.md | 10 + .../src/serve/hosted-harness-session.test.ts | 25 +++ .../cli/src/serve/hosted-harness-session.ts | 7 +- .../managedagent/store/ManagedAgentStore.java | 3 + .../store/ManagedExtensionRecordStore.java | 53 ++++-- .../store/WorkspaceExecutionStore.java | 7 +- .../store/WorkspaceLifecycleStore.java | 43 ++--- .../managedagent/ExtensionRecordJournal.java | 12 +- .../ManagedHookRecordContractTest.java | 4 +- .../store/WorkspaceLifecycleMySqlIT.java | 42 +++++ .../store/WorkspaceLifecycleStoreTest.java | 172 ++++++++++++++++++ .../qwen/code/daemon/HostedHarnessClient.java | 14 +- .../code/daemon/HostedHarnessClientTest.java | 57 ++++++ .../JdbcRuntimeBindingRepository.java | 2 +- 15 files changed, 408 insertions(+), 58 deletions(-) diff --git a/docs/design/workspace-session-active-delete-l3.md b/docs/design/workspace-session-active-delete-l3.md index fea83211ad2..9fd098dd32e 100644 --- a/docs/design/workspace-session-active-delete-l3.md +++ b/docs/design/workspace-session-active-delete-l3.md @@ -86,7 +86,12 @@ returns 409. Unexpected Store, transport or writer-authority failures return 503 and admit no execution. Authenticated cancellation of an admitted Turn is a local abort and does not require ordinary Store authorization; the local lifecycle fence still excludes cancellation. Protocol-zero close retains its -scoped exception. +scoped exception. Ordinary attachment mutations validate client identity before +Store authorization or writer renewal; the legacy close control route retains +its explicit missing-client exception. Lifecycle claims use database millisecond +time and keep their non-retryable 409 through the Broker boundary. A DELETE +classified before a concurrent CLOSE completes is reclassified under the locked +Session state, preserving CLOSED/ARCHIVED deletion as L2. Settle earlier operations, excluding this operation's lifecycle occurrences from generic cancellation. Stable occurrence IDs derive from Session, operation and @@ -110,12 +115,20 @@ Precheck authority before both plan and child dispatch, and recheck inside the journal commit transaction. A definite transactional authorization refusal leaves the writer authority retryable without consuming journal sequences. A missing response, or a refusal after an uncertain request, retains the write failure fence. +The guard checks every Hook revision, including multiple revisions in one commit: +leaving intent for a possibly started outcome requires authorization, while +not_started_proven and original dispatched-result settlement remain available. +Lifecycle settlement cannot change an already recorded Runtime identity. After validating and saving the effects receipt, upgrade the fence monotonically to DRAINING. Detach without Hooks, release owners, seal the writer, then use the reliable-close drain/stop protocol. A successor skips Hooks when the effects receipt is saved, otherwise reconstructs progress from the same H2 occurrences. Harness 404, lease expiry and worker disappearance cannot establish completion. +A confirmed same-boot detach 404 stops the SDK attachment heartbeat; refusals and +ambiguous responses retain it. Receipt recovery selects the exact occurrence and +resource through their existing primary keys, preserving raw identity and byte +validation without scanning other tenants or looping over historical candidates. Receipt recovery may skip the Harness lifecycle request entirely. Detach must therefore validate its operation authority against Session Store even when the diff --git a/docs/design/workspace-session-active-delete-l3.zh-CN.md b/docs/design/workspace-session-active-delete-l3.zh-CN.md index 0ccb0e642d7..60d66e0f8e3 100644 --- a/docs/design/workspace-session-active-delete-l3.zh-CN.md +++ b/docs/design/workspace-session-active-delete-l3.zh-CN.md @@ -68,6 +68,10 @@ placement guard 先于 retention、Session 和 journal 锁。仅检查 journal h 拒绝返回 409;意外 Store、传输或 writer 权限故障返回 503,均不放行执行。 已接纳 Turn 的认证取消是本地 abort,不要求普通 Store 授权;本地生命周期围栏 仍禁止取消生命周期操作。protocol-zero close 保留限定的例外。 +普通 attachment mutation 在 Store 授权或 writer 续租前核验 client 身份; +legacy close 控制路由保留明确的缺失 client 例外。生命周期 claim 使用数据库 +毫秒时钟,其不可重试 409 穿过 Broker 边界后保持不变。DELETE 若在并发 CLOSE +完成前被分类,仍按加锁后的 Session 状态重新分类,保留 CLOSED/ARCHIVED 的 L2 删除。 先结算此前操作,通用取消不得包含本 operation 的生命周期 occurrence。 稳定 occurrence ID 由 Session、operation、事件派生,复用 H2 的 catalog、plan、 @@ -86,11 +90,17 @@ Plan 身份使用固定的紧凑 JSON 字节,不受应用 JSON 格式化配置 plan 和子执行派发前均预检权限,并在 journal 提交事务中重新检查。明确的事务 授权拒绝不消耗 journal sequence,writer authority 可继续重试。应答丢失,或 不确定请求后再收到拒绝,仍保留写入失败围栏。 +围栏核验每次 Hook revision,包括同一提交中的连续 revision:intent 转为可能 +已启动的结果须重新授权;not_started_proven 与原已派发工作结果的结算仍可提交。 +生命周期结算不得改变已记录的 Runtime 身份。 核对并保存 effects receipt 后,围栏单向升级到 DRAINING。不运行 Hook 地 detach, 释放 owner、seal writer,再使用可靠 close 的 drain/stop 协议。接管者在 effects receipt 已保存时跳过 Hook,否则从相同 H2 occurrence 恢复进度。Harness 404、 租约过期或 worker 消失均不能证明完成。 +明确且同 boot 的 detach 404 停止 SDK attachment 心跳;拒绝和不确定应答仍保留。 +receipt 恢复通过既有主键查找精确 occurrence 与 resource,保留原身份和字节校验, +不扫描其他租户,也不遍历历史候选资源。 receipt 恢复可能完全跳过 Harness lifecycle 请求。因此,即使存活 attachment 没有本地 lifecycle 状态,detach 也必须向 Session Store 核验 operation 权限。 diff --git a/packages/cli/src/serve/hosted-harness-session.test.ts b/packages/cli/src/serve/hosted-harness-session.test.ts index bffd2368ade..39b12b46402 100644 --- a/packages/cli/src/serve/hosted-harness-session.test.ts +++ b/packages/cli/src/serve/hosted-harness-session.test.ts @@ -561,6 +561,31 @@ describe('Hosted Harness no-tool session', () => { }, ); + it.each(['title', 'prompt'] as const)( + 'checks attachment identity before ordinary Store authorization for %s', + async (route) => { + const { server, authorize } = await hookApp(); + state.authorizeOrdinary.mockClear(); + for (const clientId of [undefined, 'wrong-client']) { + const request = headers( + supertest(server).post(`/session/${SESSION_ID}/${route}`), + ); + if (clientId) request.set('X-Qwen-Client-Id', clientId); + const rejected = await request.send({ title: 'renamed' }); + expect(rejected.status).toBe(404); + expect(rejected.body.code).toBe('hosted_session_not_found'); + expect(state.authorizeOrdinary).not.toHaveBeenCalled(); + } + await authorize(supertest(server).post(`/session/${SESSION_ID}/title`)) + .send({ title: 'renamed' }) + .expect(200); + expect(state.authorizeOrdinary).toHaveBeenCalledOnce(); + await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .send({}) + .expect(204); + }, + ); + it('uses the legacy close admission for an attached protocol zero Session with Hooks', async () => { const { server, authorize, requests } = await hookApp(); state.authorizeOrdinary.mockImplementation(async (kind) => { diff --git a/packages/cli/src/serve/hosted-harness-session.ts b/packages/cli/src/serve/hosted-harness-session.ts index eafcc375ff8..6df81a0897c 100644 --- a/packages/cli/src/serve/hosted-harness-session.ts +++ b/packages/cli/src/serve/hosted-harness-session.ts @@ -2151,11 +2151,12 @@ export function registerHostedHarnessSessionRoutes( !['/lifecycle', '/detach', '/heartbeat', '/cancel'].includes(req.path) && !session.lifecycle ) { + const legacyClose = req.method === 'DELETE' && req.path === '/'; + if (!identity(req, sessions, legacyClose)) + return error(res, 404, 'hosted_session_not_found'); try { await session.stores!.authorizeOrdinary( - req.method === 'DELETE' && req.path === '/' - ? 'legacy-close' - : undefined, + legacyClose ? 'legacy-close' : undefined, ); } catch (cause) { return ordinaryAuthorizationError(res, cause); diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java index 118ae42d3f1..69ab4cf305f 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java @@ -783,6 +783,9 @@ private OperationAdmission beginLifecycle(String tenantId, String sessionId, Ope throw new ApiException(HttpStatus.NOT_FOUND, "session_not_found", "The Session was not found."); } + if (kind == OperationKind.DELETE && List.of("CLOSED", "ARCHIVED").contains(session.status())) { + protocolVersion = 0; + } if (session.workspace() != null) { if (kind == OperationKind.CLOSE || kind == OperationKind.DELETE && "ACTIVE".equals(session.status()) && protocolVersion == 1) { if (!supported || !workspaceFilesEnabled) { diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedExtensionRecordStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedExtensionRecordStore.java index 7b2543fbd34..0b5cd493f14 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedExtensionRecordStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedExtensionRecordStore.java @@ -269,6 +269,7 @@ ApplyResult apply(String tenantId, String workspaceId, String sessionId, boolean hasNewLifecycleDispatch(String tenantId, String sessionId, byte[] bytes, Function resources) { boolean dispatch = false; + var revisions = new java.util.HashMap(); for (String line : new String(bytes, StandardCharsets.UTF_8).split("\n")) { JsonNode parsed = parse(line); if (parsed == null) { @@ -294,13 +295,8 @@ boolean hasNewLifecycleDispatch(String tenantId, String sessionId, byte[] bytes, throw WorkspaceLifecycleStore.blocked("workspace_lifecycle_admission_closed"); } JsonNode next = readBody(resources.apply(payload.path("recordRef").path("resourceId").asText())); - if (!"dispatch_started".equals(next.path("run").path("execution").asText())) { - continue; - } - String field = "hook_execution".equals(domain) ? "hookExecutionId" : "registrationId"; - var previous = listRecords(tenantId, sessionId, domain).stream() - .filter(record -> next.path(field).equals(record.path(field))).findFirst(); - if (previous.isEmpty() || !"dispatch_started".equals(previous.get().path("run").path("execution").asText())) { + JsonNode previous = previousLifecycleRecord(tenantId, sessionId, domain, next, revisions); + if (requiresLifecycleDispatch(previous, next)) { dispatch = true; } } @@ -309,6 +305,7 @@ boolean hasNewLifecycleDispatch(String tenantId, String sessionId, byte[] bytes, void requireLifecycleSettlement(String tenantId, String sessionId, byte[] bytes, Function resources) { + var revisions = new java.util.HashMap(); for (String line : new String(bytes, StandardCharsets.UTF_8).split("\n")) { JsonNode parsed = parse(line); if (parsed == null) { @@ -331,19 +328,45 @@ void requireLifecycleSettlement(String tenantId, String sessionId, byte[] bytes, throw WorkspaceLifecycleStore.blocked("workspace_lifecycle_admission_closed"); } JsonNode next = readBody(resources.apply(payload.path("recordRef").path("resourceId").asText())); - String id = next.path(domain.equals("hook_execution") ? "hookExecutionId" - : "registrationId").asText(); - JsonNode previous = listRecords(tenantId, sessionId, domain).stream().filter(record -> - id.equals(record.path(domain.equals("hook_execution") ? "hookExecutionId" - : "registrationId").asText())) - .findFirst().orElse(null); - if (previous == null || "intent".equals(previous.path("run").path("execution").asText()) - && "dispatch_started".equals(next.path("run").path("execution").asText())) { + JsonNode previous = previousLifecycleRecord(tenantId, sessionId, domain, next, revisions); + if (previous == null || requiresLifecycleDispatch(previous, next)) { throw WorkspaceLifecycleStore.blocked("workspace_lifecycle_admission_closed"); } } } + private JsonNode previousLifecycleRecord(String tenantId, String sessionId, String domain, JsonNode next, + java.util.Map revisions) { + String id = next.path("hook_execution".equals(domain) ? "hookExecutionId" : "registrationId").asText(); + String key = domain + ":" + id; + JsonNode previous = revisions.get(key); + if (previous == null) { + previous = jdbc.queryForList("SELECT record_resource_id FROM qwen_managed_session_extension_record" + + " WHERE session_scope_key = ? AND record_key = ? AND tenant_id = ? AND session_id = ? AND domain = ? AND record_id = ?", + String.class, ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + ManagedExtensionProjection.recordKey(sessionId, domain, id), tenantId, sessionId, domain, id).stream().map(resource -> { + JsonNode record = readBody(readResource(tenantId, sessionId, resource)); + ManagedExtensionProjection.RECORD_BODIES.get(domain).require().accept(record); + return record; + }).findFirst().orElse(null); + } + revisions.put(key, next); + return previous; + } + + private boolean requiresLifecycleDispatch(JsonNode previous, JsonNode next) { + if (previous == null) { + return "dispatch_started".equals(next.path("run").path("execution").asText()); + } + JsonNode before = previous.path("run"); + JsonNode after = next.path("run"); + String execution = before.path("execution").asText(); + if (!List.of("", "intent").contains(execution) && !before.path("runtime").equals(after.path("runtime"))) { + throw WorkspaceLifecycleStore.blocked("workspace_lifecycle_admission_closed"); + } + return "intent".equals(execution) && !List.of("intent", "not_started_proven").contains(after.path("execution").asText()); + } + public TaskPage listTasks(String tenantId, String sessionId, Long beforeCreatedAt, String beforeTaskId, int limit) { List arguments = new ArrayList<>(); diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceExecutionStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceExecutionStore.java index 9e664d37939..f5137eb05fa 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceExecutionStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceExecutionStore.java @@ -1,5 +1,6 @@ package com.alibaba.qwen.code.managedagent.store; +import com.alibaba.qwen.code.managedagent.api.ApiException; import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionRecord; import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRecord; @@ -51,7 +52,11 @@ public void authorize(SessionRecord session) { } public void authorizeLifecycle(SessionRecord session, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { - WorkspaceLifecycleStore.requireClaim(jdbc, session.tenantId(), session.sessionId(), authority, false); + try { + WorkspaceLifecycleStore.requireClaim(jdbc, session.tenantId(), session.sessionId(), authority, false); + } catch (ApiException error) { + throw new RuntimeBrokerException(error.getStatus().value(), error.getCode(), error.getMessage(), false, error); + } authorizePassiveAttachment(session, authority); if (storageGuard != null) { storageGuard.verify(session.workspace()); diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStore.java index 381d85c38de..6473979f3b5 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStore.java @@ -138,27 +138,14 @@ public JsonNode recoverEffects(OperationRecord operation) { JsonNode definition = records.readRecordResource(operation.tenantId(), operation.sessionId(), definitionRef); receipt.set("definitionRef", definitionRef); if (definition.has("hookCatalog")) { - var executions = records.listRecords(operation.tenantId(), operation.sessionId(), "hook_execution"); for (String event : events(operation)) { - String id = occurrence(event, operation.operationId()); - var refs = jdbc.queryForList("SELECT record_resource_id FROM qwen_managed_session_extension_record" - + " WHERE tenant_id = ? AND session_id = ? AND domain = 'hook_execution' ORDER BY first_sequence", - String.class, operation.tenantId(), operation.sessionId()); - JsonNode marker = executions.stream().filter(record -> id.equals(record.path("hookExecutionId").asText())) - .findFirst().orElse(null); - if (marker == null || marker.path("resultRef").isNull() || !marker.has("resultRef")) { + String resource = latestHookExecutionResource(operation, occurrence(event, operation.operationId())); + if (resource == null) { return null; } - JsonNode ref = null; - for (String resourceId : refs) { - JsonNode candidate = resourceRef(operation, resourceId); - if (id.equals(records.readRecordResource(operation.tenantId(), operation.sessionId(), candidate) - .path("hookExecutionId").asText())) { - ref = candidate; - break; - } - } - if (ref == null) { + JsonNode ref = resourceRef(operation, resource); + JsonNode marker = records.readRecordResource(operation.tenantId(), operation.sessionId(), ref); + if (!marker.hasNonNull("resultRef")) { return null; } effects.addObject().put("event", event).set("recordRef", ref); @@ -260,10 +247,7 @@ private void verifyEffects(OperationRecord operation, JsonNode receipt) { for (int index = 0; index < events.size(); index++) { String event = events.get(index); JsonNode effect = receipt.path("effects").get(index); - String latest = jdbc.queryForList("SELECT record_resource_id FROM qwen_managed_session_extension_record WHERE" - + " tenant_id = ? AND session_id = ? AND domain = 'hook_execution' AND record_id = ? FOR UPDATE", - String.class, operation.tenantId(), operation.sessionId(), occurrence(event, operation.operationId())) - .stream().findFirst().orElse(null); + String latest = latestHookExecutionResource(operation, occurrence(event, operation.operationId())); if (latest == null || !latest.equals(effect.path("recordRef").path("resourceId").asText())) { throw blocked("workspace_lifecycle_receipt_invalid"); } @@ -301,11 +285,22 @@ private boolean neverInitialized(OperationRecord operation) { && records.listRecords(operation.tenantId(), operation.sessionId(), "hook_registration").isEmpty(); } + private String latestHookExecutionResource(OperationRecord operation, String occurrence) { + return jdbc.queryForList("SELECT record_resource_id FROM qwen_managed_session_extension_record" + + " WHERE session_scope_key = ? AND record_key = ? AND tenant_id = ? AND session_id = ?" + + " AND domain = 'hook_execution' AND record_id = ? FOR UPDATE", String.class, + ManagedSessionStore.sessionScopeKey(operation.tenantId(), operation.sessionId()), + ManagedExtensionProjection.recordKey(operation.sessionId(), "hook_execution", occurrence), + operation.tenantId(), operation.sessionId(), occurrence).stream().findFirst().orElse(null); + } + private JsonNode resourceRef(OperationRecord operation, String resource) { - return jdbc.query("SELECT * FROM qwen_managed_session_resource WHERE tenant_id = ? AND session_id = ? AND resource_id = ?", + return jdbc.query("SELECT kind, schema_version, byte_length, sha256 FROM qwen_managed_session_resource" + + " WHERE session_scope_key = ? AND tenant_id = ? AND session_id = ? AND resource_id = ?", (row, index) -> json.createObjectNode().put("resourceId", resource).put("kind", row.getString("kind")) .put("schemaVersion", row.getInt("schema_version")).put("byteLength", row.getLong("byte_length")) - .put("digest", row.getString("sha256")), operation.tenantId(), operation.sessionId(), resource).stream() + .put("digest", row.getString("sha256")), ManagedSessionStore.sessionScopeKey(operation.tenantId(), operation.sessionId()), + operation.tenantId(), operation.sessionId(), resource).stream() .findFirst().orElseThrow(() -> blocked("workspace_lifecycle_receipt_invalid")); } diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ExtensionRecordJournal.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ExtensionRecordJournal.java index 9bafdbda467..f7dd653a005 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ExtensionRecordJournal.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ExtensionRecordJournal.java @@ -24,7 +24,7 @@ * Commits journal transactions to the Session store in the record format the * Session authority writes, each carrying one Stage H record revision. */ -final class ExtensionRecordJournal { +public final class ExtensionRecordJournal { private static final ObjectMapper JSON = new ObjectMapper(); private static final String WRITER = "writer-extension"; private static final String TOKEN = "extension-writer-token-0123456789"; @@ -39,7 +39,7 @@ final class ExtensionRecordJournal { private String lastCommitDigest; private int domainEvents; - ExtensionRecordJournal(ManagedSessionStore store, String tenantId, + public ExtensionRecordJournal(ManagedSessionStore store, String tenantId, String workspaceId, String sessionId) { this.store = store; this.tenantId = tenantId; @@ -48,7 +48,7 @@ final class ExtensionRecordJournal { } /** Acquires the writer and commits the Session's genesis. */ - ExtensionRecordJournal open() { + public ExtensionRecordJournal open() { acquire().commit(genesis( "{\"subtype\":\"session_execution_engine\"}\n" + "{\"subtype\":\"managed_session_header_v1\"}\n", @@ -84,7 +84,7 @@ CommitReceipt commitMonitor(String commandId, JsonNode monitor, return receipt; } - CommitReceipt commit(CommitTransactionRequest request) { + public CommitReceipt commit(CommitTransactionRequest request) { return store.commit(tenantId, sessionId, TOKEN, request); } @@ -122,7 +122,7 @@ CommitTransactionRequest request(String operation, String commandId, editRecords, extraEvents, "monitor_run", List.of()); } - CommitTransactionRequest requestDomain(String commandId, String domain, + public CommitTransactionRequest requestDomain(String commandId, String domain, JsonNode body, List resources, long occurredAt) { return request("commitMcpRecord", commandId, bytes(body), occurredAt, event -> { }, records -> records, 0, domain, resources); @@ -168,7 +168,7 @@ operation, commandId, sha256(commandId), next, } /** Advances past a request the store committed. */ - void committed(CommitTransactionRequest request) { + public void committed(CommitTransactionRequest request) { journalRevision++; sequence = request.lastSequence(); lastCommitDigest = request.commitDigest(); diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedHookRecordContractTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedHookRecordContractTest.java index 933bc825906..5389180513a 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedHookRecordContractTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedHookRecordContractTest.java @@ -14,7 +14,7 @@ import java.nio.file.Path; import org.junit.jupiter.api.Test; -class ManagedHookRecordContractTest { +public class ManagedHookRecordContractTest { private static final ObjectMapper JSON = new ObjectMapper(); @Test @@ -48,7 +48,7 @@ void validatesTheSharedSuccessors() throws IOException { } } - static JsonNode fixtures() throws IOException { + public static JsonNode fixtures() throws IOException { Path directory = Path.of("").toAbsolutePath(); while (directory != null) { Path path = directory.resolve("packages/core/src/managed-runtime/contracts/managed-hook-record-v1.fixtures.json"); diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleMySqlIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleMySqlIT.java index a02d6bf6dac..2b14185c25c 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleMySqlIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleMySqlIT.java @@ -61,6 +61,48 @@ Fixture fixture() { return new Fixture(source); } + @Test + void lifecycleEffectsKeepOtherTenantExtensionRecordsUnlocked() throws Exception { + var fixture = fixture(); + String scope = ManagedSessionStore.sessionScopeKey("neighbor", "other-session"); + String record = ManagedExtensionProjection.recordKey("other-session", "hook_execution", "other-hook"); + fixture.jdbc.update("INSERT INTO qwen_managed_session_extension_record (session_scope_key, record_key, tenant_id, workspace_id," + + " session_id, domain, record_id, operation_hash, revision, record_resource_id, created_at, first_sequence)" + + " VALUES (?, ?, 'neighbor', 'other-workspace', 'other-session', 'hook_execution', 'other-hook', ?, 1, 'other-resource', 1, 1)", + scope, record, "a".repeat(64)); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + try (var workers = Executors.newFixedThreadPool(2)) { + var held = workers.submit(() -> fixture.transactions.execute(ignored -> { + try { + new WorkspaceLifecycleStoreTest() { + @Override + Fixture fixture() { + return fixture; + } + }.hookReceiptsUseCompactProtocolIdentitiesWithAnIndentedApplicationMapper(OperationKind.DELETE, true); + entered.countDown(); + if (!release.await(10, TimeUnit.SECONDS)) throw new AssertionError("Lifecycle hold was not released"); + return null; + } catch (Exception error) { + throw new IllegalStateException(error); + } + })); + try { + assertThat(entered.await(10, TimeUnit.SECONDS)).isTrue(); + var neighbor = workers.submit(() -> new JdbcTemplate(source).update( + "UPDATE qwen_managed_session_extension_record SET revision = revision + 1 WHERE session_scope_key = ? AND record_key = ?", + scope, record)); + assertThat(neighbor.get(2, TimeUnit.SECONDS)).isEqualTo(1); + } finally { + release.countDown(); + } + held.get(10, TimeUnit.SECONDS); + } + assertThat(fixture.jdbc.queryForObject("SELECT revision FROM qwen_managed_session_extension_record" + + " WHERE session_scope_key = ? AND record_key = ?", Long.class, scope, record)).isEqualTo(2); + } + @ParameterizedTest @MethodSource("legacyCloseClockZones") @ResourceLock("java.util.TimeZone") diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java index caef9505474..c0f7882e48b 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java @@ -3,6 +3,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.alibaba.qwen.code.managedagent.ExtensionRecordJournal; +import com.alibaba.qwen.code.managedagent.ManagedHookRecordContractTest; import com.alibaba.qwen.code.managedagent.api.ApiException; import com.alibaba.qwen.code.managedagent.api.WorkspaceSelection; import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; @@ -11,6 +13,8 @@ import com.alibaba.qwen.code.runtimebroker.AesGcmSecretProtector; import com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository; import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRepository; +import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; +import com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority; import com.alibaba.qwen.code.runtimebroker.RuntimeProvisionRequest; import com.alibaba.qwen.code.runtimebroker.RuntimeScope; import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; @@ -40,6 +44,174 @@ import org.springframework.transaction.support.TransactionTemplate; class WorkspaceLifecycleStoreTest { + @ParameterizedTest + @MethodSource("fencedHookTransitions") + void lifecycleHookTransitionsRecheckNewEffectsAndKeepOriginalSettlement(boolean authority, boolean granted, + String before, String after, boolean replacement, boolean accepted, boolean batch) throws Exception { + var fixture = fixture(); + var store = new ManagedSessionStore(fixture.jdbc); + store.setLifecycleExecution(new WorkspaceExecutionStore(fixture.jdbc, + new DataSourceTransactionManager(fixture.jdbc.getDataSource())), fixture.store); + var journal = fixture.transactions.execute(ignored -> new ExtensionRecordJournal(store, "tenant", "workspace", fixture.session).open()); + var templates = ManagedHookRecordContractTest.fixtures().get("templates"); + var data = new ManagedSessionStoreModels.CommitResource("hook-data", "hook-data", 1, 2, + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest("{}".getBytes(StandardCharsets.UTF_8))), "e30="); + ObjectNode registration = templates.get("hook_registration").deepCopy(); + registration.withObject("/catalogRef").put("digest", data.digest()); + for (String state : List.of("admitted", "running", "settled")) { + registration.withObject("/run").put("state", state); + var request = journal.requestDomain("register-" + state, "hook_registration", registration, List.of(data), 1000); + fixture.transactions.executeWithoutResult(ignored -> journal.commit(request)); + journal.committed(request); + } + ObjectNode execution = templates.get("hook_execution").deepCopy(); + execution.set("planRef", registration.get("catalogRef")); + execution.set("inputRef", registration.get("catalogRef")); + execution.withObject("/run").put("execution", "intent").putObject("runtime") + .put("runtimeBindingId", "original-binding").put("generation", "1"); + for (String state : "intent".equals(before) ? List.of("intent") + : "dispatch_started".equals(before) ? List.of("intent", "dispatch_started") + : List.of("intent", "dispatch_started", "outcome_unknown")) { + hookState(execution, state, registration.get("catalogRef")); + var request = journal.requestDomain("seed-" + state, "hook_execution", execution, List.of(), 1000); + fixture.transactions.executeWithoutResult(ignored -> journal.commit(request)); + journal.committed(request); + } + var operation = fixture.admit(OperationKind.DELETE); + fixture.jdbc.update("UPDATE managed_workspace_access SET can_create = ?", granted); + ObjectNode next = execution.deepCopy(); + hookState(next, after, registration.get("catalogRef")); + if (replacement && !batch) next.withObject("/run/runtime").put("generation", "2"); + assertThat(ManagedHookRecords.isExecutionSuccessor(execution, next)).isTrue(); + var first = journal.requestDomain("fenced", "hook_execution", next, List.of(), 1000); + var request = first; + if (batch) { + ObjectNode resumed = next.deepCopy(); + hookState(resumed, "running_attached", registration.get("catalogRef")); + if (replacement) resumed.withObject("/run/runtime").put("generation", "2"); + assertThat(ManagedHookRecords.isExecutionSuccessor(next, resumed)).isTrue(); + var second = journal.requestDomain("fenced", "hook_execution", resumed, List.of(), 1000); + String[] firstLines = new String(Base64.getDecoder().decode(first.recordBytesBase64()), StandardCharsets.UTF_8).split("\n"); + String[] secondLines = new String(Base64.getDecoder().decode(second.recordBytesBase64()), StandardCharsets.UTF_8).split("\n"); + var event = (ObjectNode) fixture.json.readTree(secondLines[0]); + event.withObject("/managedSession").put("sequence", first.lastSequence() + 1).put("eventId", "resumed-event"); + byte[] bytes = (firstLines[0] + "\n" + event + "\n" + firstLines[1] + "\n").getBytes(StandardCharsets.UTF_8); + var closure = new java.util.ArrayList<>(first.resources()); + closure.addAll(second.resources()); + request = new ManagedSessionStoreModels.CommitTransactionRequest(first.workspaceId(), first.writerId(), first.writerGeneration(), + first.expectedJournalRevision(), first.expectedCommittedSequence(), first.transactionId(), first.operation(), first.commandId(), + first.contentDigest(), first.firstSequence(), first.lastSequence() + 1, 2, first.eventsDigest(), first.previousCommitDigest(), + first.commitDigest(), first.activationEpoch(), null, 3, Base64.getEncoder().encodeToString(bytes), + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)), closure); + } + var commitRequest = request; + long revision = fixture.jdbc.queryForObject("SELECT journal_revision FROM qwen_managed_session_journal_head", Long.class); + int resources = fixture.jdbc.queryForObject("SELECT COUNT(*) FROM qwen_managed_session_resource", Integer.class); + Runnable commit = () -> fixture.transactions.executeWithoutResult(ignored -> store.commit("tenant", fixture.session, + "extension-writer-token-0123456789", commitRequest, authority ? WorkspaceLifecycleStore.authority(operation) : null)); + if (accepted) { + commit.run(); + assertThat(fixture.jdbc.queryForObject("SELECT journal_revision FROM qwen_managed_session_journal_head", Long.class)) + .isEqualTo(revision + 1); + } else { + assertThatThrownBy(commit::run).isInstanceOfSatisfying(ApiException.class, error -> { + assertThat(error.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(error.getCode()).isEqualTo(authority && !replacement + ? "workspace_lifecycle_authorization_revoked" : "workspace_lifecycle_admission_closed"); + }); + assertThat(fixture.jdbc.queryForObject("SELECT journal_revision FROM qwen_managed_session_journal_head", Long.class)).isEqualTo(revision); + assertThat(fixture.jdbc.queryForObject("SELECT COUNT(*) FROM qwen_managed_session_resource", Integer.class)).isEqualTo(resources); + } + } + + static Stream fencedHookTransitions() { + Stream single = Stream.concat(Stream.of(true, false).flatMap(authority -> Stream.of( + Arguments.of(authority, false, "intent", "dispatch_started", false, false), + Arguments.of(authority, false, "intent", "outcome_unknown", false, false), + Arguments.of(authority, false, "intent", "corrupt", false, false), + Arguments.of(authority, false, "intent", "not_started_proven", false, true), + Arguments.of(authority, false, "dispatch_started", "outcome_unknown", false, true), + Arguments.of(authority, false, "dispatch_started", "settled", false, true), + Arguments.of(authority, false, "outcome_unknown", "running_attached", false, true), + Arguments.of(authority, true, "outcome_unknown", "running_attached", true, false))), + Stream.of(Arguments.of(true, true, "intent", "dispatch_started", false, true), + Arguments.of(true, true, "intent", "outcome_unknown", false, true))).map(arguments -> { + var values = java.util.Arrays.copyOf(arguments.get(), 7); + values[6] = false; + return Arguments.of(values); + }); + return Stream.concat(single, Stream.of(Arguments.of(true, true, "intent", "outcome_unknown", true, false, true), + Arguments.of(true, true, "intent", "outcome_unknown", false, true, true))); + } + + private static void hookState(ObjectNode record, String execution, JsonNode result) { + var run = record.withObject("/run").put("execution", execution).putNull("reason"); + if (List.of("outcome_unknown", "corrupt").contains(execution)) { + run.put("state", "recovery_blocked").put("reason", "corrupt".equals(execution) ? "execution_corrupt" : execution); + } else if ("settled".equals(execution)) { + run.put("state", "settled"); + record.set("resultRef", result); + } else if ("not_started_proven".equals(execution)) { + run.put("state", "cancelled"); + } else { + run.put("state", "intent".equals(execution) ? "admitted" : "running"); + } + } + + @Test + void lifecycleClaimRefusalRemainsANonRetryableBrokerConflict() { + var fixture = fixture(); + var operation = fixture.admit(OperationKind.DELETE); + var execution = new WorkspaceExecutionStore(fixture.jdbc, new DataSourceTransactionManager(fixture.jdbc.getDataSource())); + var session = fixture.store.requireSession("tenant", fixture.session); + execution.authorizeLifecycle(session, WorkspaceLifecycleStore.authority(operation)); + assertThatThrownBy(() -> execution.authorizeLifecycle(session, + new RuntimeLifecycleAuthority(operation.operationId(), operation.claimGeneration() + 1))) + .isInstanceOfSatisfying(RuntimeBrokerException.class, error -> { + assertThat(error.getStatusCode()).isEqualTo(409); + assertThat(error.getCode()).isEqualTo("workspace_lifecycle_claim_fenced"); + assertThat(error.isRetryable()).isFalse(); + }); + fixture.jdbc.update("UPDATE managed_agent_operation SET lease_until = 0"); + assertThatThrownBy(() -> execution.authorizeLifecycle(session, WorkspaceLifecycleStore.authority(operation))) + .isInstanceOfSatisfying(RuntimeBrokerException.class, error -> { + assertThat(error.getStatusCode()).isEqualTo(409); + assertThat(error.getCode()).isEqualTo("workspace_lifecycle_claim_fenced"); + assertThat(error.isRetryable()).isFalse(); + }); + } + + @Test + void brokerAdmissionRejectsAnExpiredMillisecondLifecycleClaim() { + var fixture = fixture(); + var operation = fixture.admit(OperationKind.DELETE); + long now = fixture.jdbc.queryForObject("SELECT UNIX_TIMESTAMP(), EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(6))", + (row, index) -> row.getLong(1) * 1000 + row.getLong(2) / 1000); + fixture.jdbc.update("UPDATE qwen_runtime_harness_drain SET claim_lease_until = ?", now - 1); + assertThatThrownBy(() -> fixture.bindings.requireHarnessAdmission(fixture.scope(), fixture.session, + WorkspaceLifecycleStore.authority(operation))).isInstanceOfSatisfying(RuntimeBrokerException.class, error -> { + assertThat(error.getStatusCode()).isEqualTo(409); + assertThat(error.getCode()).isEqualTo("runtime_admission_closed"); + assertThat(error.isRetryable()).isFalse(); + }); + } + + @Test + void deleteReclassifiesACompletedCloseUnderTheSessionLock() { + var fixture = fixture(); + var close = fixture.admit(OperationKind.CLOSE); + assertThat(fixture.transactions.execute(ignored -> fixture.lifecycle.recoverEffects(close))).isNotNull(); + fixture.transactions.execute(ignored -> fixture.store.completeOperation("tenant", fixture.session, close.operationId(), + close.leaseOwner(), close.claimGeneration(), true)); + assertThat(fixture.store.requireSession("tenant", fixture.session).status()).isEqualTo("CLOSED"); + var admission = fixture.transactions.execute(ignored -> fixture.store.beginWorkspaceLifecycle("tenant", fixture.session, + OperationKind.DELETE, "owner", "a".repeat(64), "stale-active-delete", "digest", true, 1)); + assertThat(admission.operation().lifecycleProtocolVersion()).isZero(); + assertThat(admission.operation().sessionStatusBefore()).isEqualTo("CLOSED"); + assertThat(fixture.jdbc.queryForObject("SELECT COUNT(*) FROM qwen_runtime_harness_drain", Integer.class)).isEqualTo(1); + assertThat(fixture.jdbc.queryForObject("SELECT phase FROM qwen_runtime_harness_drain", String.class)).isEqualTo("DRAINING"); + } + @ParameterizedTest @MethodSource("invalidJournalRecords") void lifecycleJournalValidationRejectsInvalidRecordsAndRollsBack(String line, String mode) throws Exception { diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java index ce82f5852d5..9c0b1f3bcb1 100644 --- a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java +++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java @@ -542,21 +542,25 @@ public void detachLifecycle(HarnessSessionRef session, Map autho HarnessSessionRef ref = requireSessionRef(session); HttpSupport.Response response = sendMutation(sessionPath(ref.getHarnessSessionId()) + "/detach", Map.of("authority", authority), ref.getHarnessClientId(), "POST /session/:id/detach"); - requireMutationStatus(response, 204, "POST /session/:id/detach"); + if (response.getStatusCode() != 404) { + requireMutationStatus(response, 204, "POST /session/:id/detach"); + } removeAttachment(ref); } public void detachLifecycle(String harnessSessionId, Map authority) { ensureOpen(); String sessionId = requireUuid(harnessSessionId, "harnessSessionId"); + AttachmentState state = attachments.get(sessionId); HttpSupport.Response response = sendMutation(sessionPath(sessionId) + "/detach", Map.of("authority", authority), null, "POST /session/:id/detach"); - requireMutationStatus(response, 204, "POST /session/:id/detach"); - AttachmentState state = attachments.remove(sessionId); - if (state != null) { + if (response.getStatusCode() != 404) { + requireMutationStatus(response, 204, "POST /session/:id/detach"); + } + if (state != null && attachments.remove(sessionId, state)) { state.cancel(); + activePrompts.remove(sessionId); } - activePrompts.remove(sessionId); } public void detachSession(HarnessSessionRef session) { diff --git a/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java index 68ae99c244a..81cc0bf1f0a 100644 --- a/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java +++ b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java @@ -20,11 +20,16 @@ import java.util.concurrent.Executors; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import java.util.stream.Stream; class HostedHarnessClientTest { private static final String BOOT_ID = @@ -69,6 +74,58 @@ void tearDown() { } } + @ParameterizedTest + @MethodSource("lifecycleDetachResponses") + void lifecycleDetachStopsHeartbeatOnlyAfterConfirmedAbsence(boolean byId, int status, boolean sameBoot) throws Exception { + createSessionRoute(); + AtomicBoolean detached = new AtomicBoolean(); + CountDownLatch firstHeartbeat = new CountDownLatch(1); + CountDownLatch laterHeartbeats = new CountDownLatch(2); + server.createContext("/session/" + SESSION_ID + "/heartbeat", exchange -> { + firstHeartbeat.countDown(); + if (detached.get()) laterHeartbeats.countDown(); + sendSessionJson(exchange, 200, "{\"sessionId\":\"" + SESSION_ID + + "\",\"clientId\":\"" + CLIENT_ID + "\",\"lastSeenAt\":123}"); + }); + server.createContext("/session/" + SESSION_ID + "/detach", exchange -> { + assertPrivateHeaders(exchange, !byId); + if (status == 204) { + exchange.getResponseHeaders().set(HostedHarnessClient.BOOT_ID_HEADER, BOOT_ID); + exchange.getResponseHeaders().set("Connection", "close"); + exchange.sendResponseHeaders(204, -1); + exchange.close(); + } else { + exchange.getResponseHeaders().set(HostedHarnessClient.BOOT_ID_HEADER, sameBoot ? BOOT_ID : OTHER_BOOT_ID); + sendJson(exchange, status, "{\"code\":\"session_not_found\"}", false); + } + }); + try (HostedHarnessClient client = HostedHarnessClient.builder().baseUri(baseUri).bearerToken("harness-token") + .capabilityDigest(DIGEST).heartbeatInterval(Duration.ofMillis(20)).build()) { + HarnessSessionRef session = createSession(client); + assertTrue(firstHeartbeat.await(2, TimeUnit.SECONDS)); + var authority = Map.of("operationId", "delete-1", "claimGeneration", 2); + Runnable detach = () -> { + if (byId) client.detachLifecycle(SESSION_ID, authority); + else client.detachLifecycle(session, authority); + }; + boolean confirmed = sameBoot && (status == 204 || status == 404); + if (confirmed) detach.run(); + else { + Class expected = sameBoot ? status == 500 ? MutationOutcomeUnknownException.class + : DaemonHttpException.class : HostedHarnessGenerationException.class; + assertThrows(expected, detach::run); + } + detached.set(true); + assertEquals(!confirmed, laterHeartbeats.await(200, TimeUnit.MILLISECONDS)); + } + } + + static Stream lifecycleDetachResponses() { + return Stream.of(true, false).flatMap(byId -> Stream.of(Arguments.of(byId, 204, true), + Arguments.of(byId, 404, true), Arguments.of(byId, 403, true), Arguments.of(byId, 409, true), + Arguments.of(byId, 500, true), Arguments.of(byId, 404, false))); + } + @Test void lifecycleSettlementRetainsAttachmentAndDetachCarriesTheNewClaim() { server.removeContext("/capabilities"); diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/JdbcRuntimeBindingRepository.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/JdbcRuntimeBindingRepository.java index 16aa8434de1..892b2d9794c 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/JdbcRuntimeBindingRepository.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/JdbcRuntimeBindingRepository.java @@ -395,7 +395,7 @@ private static void requireHarnessAdmission(Connection connection, RuntimeScope if (authority == null || !"LIFECYCLE_ONLY".equals(row.getString("phase")) || !authority.operationId().equals(row.getString("operation_id")) || authority.claimGeneration() != row.getLong("claim_generation") - || row.getLong("claim_lease_until") <= JdbcRepositorySupport.databaseNow(connection).toEpochMilli()) { + || row.getLong("claim_lease_until") <= JdbcRepositorySupport.databaseNowPrecise(connection).toEpochMilli()) { throw admissionClosed(); } } From facc4ab1f9bd1513a214e71a483032638377d7f0 Mon Sep 17 00:00:00 2001 From: jinye Date: Mon, 5 Oct 2026 13:23:13 +0800 Subject: [PATCH 10/17] fix(managed-agent): preserve main credential and migration contracts (#13354) --- .../workspace-session-active-delete-l3.md | 11 +++- ...orkspace-session-active-delete-l3.zh-CN.md | 7 ++- .../http-managed-session-store.test.ts | 4 +- .../store/ManagedSessionStore.java | 18 ++++--- ...l => V41__workspace_session_lifecycle.sql} | 0 .../ManagedSessionStoreCredentialTest.java | 52 +++++++++++++++++++ 6 files changed, 81 insertions(+), 11 deletions(-) rename packages/sdk-java/managed-agent-server/src/main/resources/db/migration/{V40__workspace_session_lifecycle.sql => V41__workspace_session_lifecycle.sql} (100%) diff --git a/docs/design/workspace-session-active-delete-l3.md b/docs/design/workspace-session-active-delete-l3.md index 9fd098dd32e..7e3766f0b01 100644 --- a/docs/design/workspace-session-active-delete-l3.md +++ b/docs/design/workspace-session-active-delete-l3.md @@ -174,8 +174,8 @@ accept those Sessions; this PR does not implement compaction recovery. ## 4. Compatibility and rollout -Add lifecycle migration V40 after the existing V35 tool-profile and V36–V39 -journal/query migrations; preserve those migrations and V32. Historical admitted operations retain their +Add lifecycle migration V41 after the existing V35 tool-profile, V36–V39 +journal/query and V40 creator migrations; preserve those migrations and V32. Historical admitted operations retain their original protocol and evidence, without new Hook identities. Live protocol-zero close attachments retain their legacy DELETE and original Hook control path only while their persisted close claim is valid. Ordinary @@ -188,6 +188,13 @@ capability rejects admission rather than falling back to legacy DELETE. Do not roll back to old coordinators while L3 operations remain unfinished. L2 CLOSED/ARCHIVED deletion remains independent of Harness availability. +The configured Store writer-credential policy applies before lifecycle or +ordinary writer state is read. Lifecycle loads carry the provisioned credential +and transport policy unchanged. Cleanup still requires the original writer and +current claim; an expired or SEALED cleanup grant does not bypass credential +validation. A credential-policy change may leave recovery blocked and cannot +justify a replacement writer or Runtime. + Implementation order: protocol and fences; scoped authority and receipts; close semantics; ACTIVE delete admission and capability; recovery validation. Synchronize canonical OpenAPI and related bilingual designs. diff --git a/docs/design/workspace-session-active-delete-l3.zh-CN.md b/docs/design/workspace-session-active-delete-l3.zh-CN.md index 60d66e0f8e3..3a4d33cc794 100644 --- a/docs/design/workspace-session-active-delete-l3.zh-CN.md +++ b/docs/design/workspace-session-active-delete-l3.zh-CN.md @@ -134,7 +134,7 @@ L3 返回 `workspace_lifecycle_journal_unverified`。未来 compaction 必须 ## 4. 兼容与启用 -在已有 V35 工具配置及 V36–V39 journal/查询迁移之后新增 V40 生命周期迁移, +在已有 V35 工具配置、V36–V39 journal/查询迁移及 V40 创建者迁移之后新增 V41 生命周期迁移, 保留这些迁移和 V32。 升级前已接纳的操作沿用原协议与证据,不产生新 Hook 身份。 仍存活的 protocol-zero close attachment 仅在持久 close claim 有效时保留原 DELETE @@ -144,6 +144,11 @@ L3 返回 `workspace_lifecycle_journal_unverified`。未来 compaction 必须 不回退到旧 DELETE。存在未完成 L3 操作时不回滚到旧 coordinator。 L2 CLOSED/ARCHIVED 删除继续独立于 Harness 可用性。 +读取生命周期或普通 writer 状态前,先执行已配置的 Store writer 凭证校验。 +生命周期加载原样传递签发的凭证及传输策略。cleanup 仍须使用原 writer 和当前 +claim;过期或 SEALED 的 cleanup grant 不绕过凭证校验。凭证策略变更可能使恢复 +持续阻塞,不能因此创建替代 writer 或 Runtime。 + 实施顺序:协议与围栏;限定权限和回执;close 语义;ACTIVE delete 准入与 capability;恢复验证。同步 canonical OpenAPI 和相关中英文设计。 diff --git a/packages/core/src/managed-runtime/http-managed-session-store.test.ts b/packages/core/src/managed-runtime/http-managed-session-store.test.ts index db0bbaf89e9..1ef54b75746 100644 --- a/packages/core/src/managed-runtime/http-managed-session-store.test.ts +++ b/packages/core/src/managed-runtime/http-managed-session-store.test.ts @@ -169,7 +169,7 @@ describe('HTTP Managed Session store', () => { operation: string | null; }> = []; const stores = createHttpManagedSessionStores({ - baseUrl: 'http://session-store.test', + baseUrl: 'https://session-store.test', sessionKey: SESSION_KEY, writerId: 'harness-a', writerToken: TOKEN_A, @@ -229,7 +229,7 @@ describe('HTTP Managed Session store', () => { const server = new FakeManagedSessionStore(); const authorize = vi.fn(); const stores = createHttpManagedSessionStores({ - baseUrl: 'http://session-store.test', + baseUrl: 'https://session-store.test', sessionKey: SESSION_KEY, writerId: 'harness-a', writerToken: TOKEN_A, diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java index 020ce7a0f40..f2665d40698 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java @@ -144,6 +144,9 @@ public void setLifecycleExecution(WorkspaceExecutionStore execution, AgentStateS public void authorizeLifecycle(String tenantId, String sessionId, String writerToken, ManagedSessionStoreModels.AuthorizeLifecycleRequest request, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { + validateScope(tenantId, request.workspaceId(), sessionId); + requireCredential(tenantId, request.workspaceId(), sessionId, + writerToken); WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); ToolPublicationRetentionStore.lockTenant(jdbc, tenantId); jdbc.queryForList("SELECT session_id FROM managed_agent_session WHERE tenant_id = ? AND session_id = ? FOR UPDATE", tenantId, sessionId); @@ -184,6 +187,9 @@ private void checkLifecycleWriter(String tenantId, String sessionId, @Transactional public void authorizeOrdinary(String tenantId, String sessionId, String writerToken, ManagedSessionStoreModels.AuthorizeLifecycleRequest request) { + validateScope(tenantId, request.workspaceId(), sessionId); + requireCredential(tenantId, request.workspaceId(), sessionId, + writerToken); WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); ToolPublicationRetentionStore.lockTenant(jdbc, tenantId); if (!"legacy-close".equals(request.kind()) || !WorkspaceLifecycleStore.legacyClose(jdbc, tenantId, sessionId)) { @@ -268,15 +274,15 @@ public WriterGrant acquireWriter(String tenantId, String sessionId, @Transactional public WriterGrant acquireWriter(String tenantId, String sessionId, String writerToken, AcquireWriterRequest request, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { + validateScope(tenantId, request.workspaceId(), sessionId); + requireCredential(tenantId, request.workspaceId(), sessionId, + writerToken); checkLifecycleWriter(tenantId, sessionId, authority); if (authority != null) { WorkspaceLifecycleStore.requireClaim(jdbc, tenantId, sessionId, authority, false); } else { requireOrdinaryAdmission(tenantId, sessionId); } - validateScope(tenantId, request.workspaceId(), sessionId); - requireCredential(tenantId, request.workspaceId(), sessionId, - writerToken); validateStableId(request.writerId(), "writerId"); validateLeaseMillis(request.leaseMillis()); ToolPublicationRetentionStore.lockTenant(jdbc, tenantId); @@ -374,10 +380,10 @@ public WriterGrant renewWriter(String tenantId, String sessionId, @Transactional public WriterGrant renewWriter(String tenantId, String sessionId, String writerToken, RenewWriterRequest request, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { - checkLifecycleWriter(tenantId, sessionId, authority); validateScope(tenantId, request.workspaceId(), sessionId); requireCredential(tenantId, request.workspaceId(), sessionId, writerToken); + checkLifecycleWriter(tenantId, sessionId, authority); validateStableId(request.writerId(), "writerId"); validateCounter(request.writerGeneration(), "writerGeneration", 1); validateLeaseMillis(request.leaseMillis()); @@ -407,10 +413,10 @@ public SealReceipt sealWriter(String tenantId, String sessionId, @Transactional public SealReceipt sealWriter(String tenantId, String sessionId, String writerToken, SealWriterRequest request, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { - checkLifecycleWriter(tenantId, sessionId, authority); validateScope(tenantId, request.workspaceId(), sessionId); requireCredential(tenantId, request.workspaceId(), sessionId, writerToken); + checkLifecycleWriter(tenantId, sessionId, authority); validateStableId(request.writerId(), "writerId"); validateCounter(request.writerGeneration(), "writerGeneration", 1); HeadRow head = requireHeadForUpdate(tenantId, sessionId); @@ -484,10 +490,10 @@ public CommitReceipt commit(String tenantId, String sessionId, @Transactional public CommitReceipt commit(String tenantId, String sessionId, String writerToken, CommitTransactionRequest request, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { - checkLifecycleWriter(tenantId, sessionId, authority); validateScope(tenantId, request.workspaceId(), sessionId); requireCredential(tenantId, request.workspaceId(), sessionId, writerToken); + checkLifecycleWriter(tenantId, sessionId, authority); validateStableId(request.writerId(), "writerId"); ValidatedCommit validated = validateCommit(request); HeadRow head = requireHeadForUpdate(tenantId, sessionId); diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V40__workspace_session_lifecycle.sql b/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V41__workspace_session_lifecycle.sql similarity index 100% rename from packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V40__workspace_session_lifecycle.sql rename to packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V41__workspace_session_lifecycle.sql diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStoreCredentialTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStoreCredentialTest.java index 0089880fed5..9afb6ef7ffe 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStoreCredentialTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStoreCredentialTest.java @@ -2,12 +2,18 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; import com.alibaba.qwen.code.managedagent.api.ApiException; import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; import org.flywaydb.core.Flyway; import org.h2.jdbcx.JdbcDataSource; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.springframework.jdbc.core.JdbcTemplate; /** @@ -18,6 +24,52 @@ class ManagedSessionStoreCredentialTest { private static final String KEY = "0123456789abcdef0123456789abcdef"; + @ParameterizedTest + @CsvSource({"ordinary", "lifecycle", "acquire", "renew", "seal", "commit"}) + void lifecycleWriterPathsRefuseForeignCredentialsBeforeStateLookup(String route) { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.getDataSource()).thenReturn(new JdbcDataSource()); + ManagedSessionStore store = new ManagedSessionStore(jdbc); + ManagedAgentProperties properties = new ManagedAgentProperties(); + properties.getSessionStore().setBindingKey(KEY); + WriterCredentialPolicy policy = new WriterCredentialPolicy(properties); + store.setCredentials(policy); + reset(jdbc); + + for (String token : new String[]{null, "self-minted-token-self-minted-token-0", + policy.issue("foreign-tenant", "workspace", "session"), + policy.issue("tenant", "foreign-workspace", "session"), + policy.issue("tenant", "workspace", "foreign-session")}) { + assertThatThrownBy(() -> invokeWriterPath(store, route, token)) + .isInstanceOfSatisfying(ApiException.class, error -> { + assertThat(error.getStatus().value()).isEqualTo(403); + assertThat(error.getCode()).isEqualTo("writer_credential_invalid"); + }); + verifyNoInteractions(jdbc); + } + } + + private static void invokeWriterPath(ManagedSessionStore store, String route, String token) { + var authority = new com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority("operation", 1); + switch (route) { + case "ordinary" -> store.authorizeOrdinary("tenant", "session", token, + new ManagedSessionStoreModels.AuthorizeLifecycleRequest("workspace", "writer", 1)); + case "lifecycle" -> store.authorizeLifecycle("tenant", "session", token, + new ManagedSessionStoreModels.AuthorizeLifecycleRequest("workspace", "writer", 1), authority); + case "acquire" -> store.acquireWriter("tenant", "session", token, + new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "writer", 60_000L), authority); + case "renew" -> store.renewWriter("tenant", "session", token, + new ManagedSessionStoreModels.RenewWriterRequest("workspace", "writer", 1, 60_000L), authority); + case "seal" -> store.sealWriter("tenant", "session", token, + new ManagedSessionStoreModels.SealWriterRequest("workspace", "writer", 1), authority); + case "commit" -> store.commit("tenant", "session", token, + new ManagedSessionStoreModels.CommitTransactionRequest("workspace", "writer", 1, 0, 0, + "transaction", "operation", "command", "0".repeat(64), 0, 0, 0, null, null, null, + 0, null, 1, "", "0".repeat(64), java.util.List.of()), authority); + default -> throw new IllegalArgumentException(route); + } + } + @Test void publicationWriterLockRequiresTheBoundCredential() { JdbcDataSource dataSource = new JdbcDataSource(); From c65e46d04e61cd8483f90f4d93ef43dfb5598876 Mon Sep 17 00:00:00 2001 From: jinye Date: Tue, 6 Oct 2026 00:00:46 +0800 Subject: [PATCH 11/17] codex: address PR review feedback (#13354) --- .../workspace-session-active-delete-l3.md | 13 ++ ...orkspace-session-active-delete-l3.zh-CN.md | 9 + .../src/serve/hosted-harness-session.test.ts | 207 +++++++++++++++++- .../cli/src/serve/hosted-harness-session.ts | 67 +++++- .../http-managed-session-store.test.ts | 72 ++++++ .../http-managed-session-store.ts | 15 +- .../store/ManagedSessionStore.java | 3 + .../store/WorkspaceLifecycleStoreTest.java | 17 +- .../qwen/code/daemon/HostedHarnessClient.java | 14 +- .../code/daemon/HostedHarnessClientTest.java | 78 +++++++ .../runtimebroker/RuntimeBrokerService.java | 4 +- .../RuntimeBrokerServiceTest.java | 21 ++ 12 files changed, 499 insertions(+), 21 deletions(-) diff --git a/docs/design/workspace-session-active-delete-l3.md b/docs/design/workspace-session-active-delete-l3.md index 7e3766f0b01..f9c30210061 100644 --- a/docs/design/workspace-session-active-delete-l3.md +++ b/docs/design/workspace-session-active-delete-l3.md @@ -105,6 +105,19 @@ Plan identities use fixed compact JSON bytes, independent of application JSON formatting. Recovery rejects pending, failed or cancelled local Session routes without waiting for ordinary acquisition or propagating its outcome. +A successor lifecycle load may adopt an existing idle files attachment only +after its normalized Store descriptor matches the original grant and the Store +accepts the current claim on the original writer. It returns the original client +identity without reopening the Session or replaying Hooks. Busy, foreign and +refused claims retain the attachment and its previous authority. Client identity +is checked before disclosing the local lifecycle fence, preserving the explicit +legacy close exception. Cancelling a never-dispatched original execution may +settle its durable cancellation through a drain fence without invoking the worker; +new dispatch remains excluded. Typed transactional claim refusals retain their +409 code and roll back all journal mutations. Confirmed lifecycle detach removes +only the captured attachment and prompt state; a late response cannot clear a +replacement's heartbeat or prompt watermark. + Before each new side-effect dispatch, check current ACL, mount and identity. After revocation, lookup and settlement of already dispatched work continue; undispatched Hooks stay recovery_blocked until authority returns. Unknown effects diff --git a/docs/design/workspace-session-active-delete-l3.zh-CN.md b/docs/design/workspace-session-active-delete-l3.zh-CN.md index 3a4d33cc794..299cc64c281 100644 --- a/docs/design/workspace-session-active-delete-l3.zh-CN.md +++ b/docs/design/workspace-session-active-delete-l3.zh-CN.md @@ -83,6 +83,15 @@ Plan 身份使用固定的紧凑 JSON 字节,不受应用 JSON 格式化配置 恢复遇到在途、失败或已取消的本地 Session 路由时直接拒绝, 不等待普通 acquire,也不传播其结果。 +接管者的 lifecycle load 只有在规范化 Store descriptor 与原 grant 完全一致、 +且 Store 接受原 writer 上的当前 claim 后,才可复用已存在且空闲的 files attachment。 +应答返回原 client 身份,不重新打开 Session 或重放 Hook。busy、外来身份或 claim +拒绝均保留原 attachment 与此前权限。返回本地生命周期围栏前核验 client 身份, +仍保留明确的 legacy close 例外。原从未派发执行的取消可穿过 drain 围栏结算持久 +取消结果,不调用 worker;新派发仍被拒绝。事务中的 typed claim 拒绝保留原 409 +code,并回滚全部 journal mutation。确认 lifecycle detach 后仅删除请求前捕获的 +attachment 与 prompt 状态;晚到应答不得清除替代 attachment 的心跳或 prompt 水位。 + 每次新副作用派发前核对当前 ACL、挂载和身份。撤销后仍可查询、结算已派发工作; 未派发 Hook 保持 recovery_blocked,直至恢复权限。unknown 保留所有权,不能 变成取消或完成证明。沿用 H2 可能无限期阻塞的限制,独立由 #13133 跟踪。 diff --git a/packages/cli/src/serve/hosted-harness-session.test.ts b/packages/cli/src/serve/hosted-harness-session.test.ts index 39b12b46402..4e6b0a8551f 100644 --- a/packages/cli/src/serve/hosted-harness-session.test.ts +++ b/packages/cli/src/serve/hosted-harness-session.test.ts @@ -372,7 +372,15 @@ async function hookApp(asyncEnd = false) { expect(created.status).toBe(200); const authorize = (request: supertest.Test) => headers(request).set('X-Qwen-Client-Id', created.body.clientId); - return { server, authorize, definition, catalog, requests, release }; + return { + server, + authorize, + definition, + catalog, + requests, + release, + clientId: created.body.clientId, + }; } describe('Hosted Harness no-tool session', () => { @@ -502,6 +510,203 @@ describe('Hosted Harness no-tool session', () => { }, ); + it.each(['close', 'delete'] as const)( + 'adopts the original idle attachment for %s without reopening or replaying Hooks', + async (kind) => { + const { server, authorize, definition, requests, clientId } = + await hookApp(); + const descriptorCount = state.storeOptions.length; + const transcriptPath = path.join(state.root, `${SESSION_ID}.jsonl`); + const transcript = await readFile(transcriptPath, 'utf8'); + state.assertWritable.mockClear(); + const authority = { operationId: 'adopt-original', claimGeneration: 1 }; + const load = () => + headers(supertest(server).post(`/session/${SESSION_ID}/load`)).send({ + ...definition, + lifecycleAuthority: authority, + }); + const adopted = await load(); + expect(adopted.status).toBe(200); + expect(adopted.body.clientId).toBe(clientId); + expect(state.storeOptions).toHaveLength(descriptorCount); + expect(state.assertWritable).toHaveBeenCalledTimes(1); + expect(await readFile(transcriptPath, 'utf8')).toBe(transcript); + expect(requests).toEqual([]); + const operation = { + kind, + sessionKey: { + tenantId: 'tenant', + workspaceId: 'workspace', + sessionId: SESSION_ID, + }, + authority, + }; + await authorize( + supertest(server).post(`/session/${SESSION_ID}/lifecycle`), + ) + .send(operation) + .expect(200); + expect((await load()).body.clientId).toBe(adopted.body.clientId); + await authorize( + supertest(server).post(`/session/${SESSION_ID}/lifecycle`), + ) + .send(operation) + .expect(200); + expect( + requests + .filter((request) => request.kind === 'hook-execute') + .map((request) => request.input.hook_event_name), + ).toEqual( + kind === 'close' ? ['SessionEnd'] : ['SessionEnd', 'SessionDelete'], + ); + for (const route of ['prompt', 'cancel']) { + await headers( + supertest(server).post(`/session/${SESSION_ID}/${route}`), + ).expect(404); + await headers(supertest(server).post(`/session/${SESSION_ID}/${route}`)) + .set('X-Qwen-Client-Id', randomUUID()) + .expect(404); + await authorize( + supertest(server).post(`/session/${SESSION_ID}/${route}`), + ).expect(409); + } + await headers(supertest(server).delete(`/session/${SESSION_ID}`)) + .set('X-Qwen-Client-Id', randomUUID()) + .expect(404); + await headers(supertest(server).delete(`/session/${SESSION_ID}`)).expect( + 409, + ); + await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .send({ authority }) + .expect(204); + expect(state.model).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { tenantId: 'foreign' }, + { workspaceId: 'foreign' }, + { writerToken: 'f'.repeat(40) }, + { baseUrl: 'http://foreign-store.test' }, + { writerId: randomUUID() }, + ])( + 'refuses lifecycle adoption with a different original grant: %s', + async (changed) => { + const { server, authorize, definition, requests } = await hookApp(); + state.assertWritable.mockClear(); + await headers(supertest(server).post(`/session/${SESSION_ID}/load`)) + .send({ + ...definition, + managedSessionStore: { ...store(), ...changed }, + lifecycleAuthority: { operationId: 'adopt', claimGeneration: 1 }, + }) + .expect(409); + expect(state.assertWritable).not.toHaveBeenCalled(); + expect(state.setLifecycleAuthority).not.toHaveBeenCalled(); + expect(requests).toEqual([]); + await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .send({}) + .expect(204); + }, + ); + + it('restores original authority after refused adoption and guards concurrent claims', async () => { + const { server, authorize, definition, requests } = await hookApp(); + const load = (operationId: string) => + headers(supertest(server).post(`/session/${SESSION_ID}/load`)).send({ + ...definition, + lifecycleAuthority: { operationId, claimGeneration: 1 }, + }); + state.assertWritable.mockRejectedValueOnce(new Error('claim refused')); + expect((await load('stale')).status).toBe(503); + expect(state.setLifecycleAuthority).toHaveBeenLastCalledWith(undefined); + let release!: () => void; + state.assertWritable.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve(undefined); + }), + ); + const pending = load('current').then((response) => response); + await vi.waitFor(() => expect(release).toBeDefined()); + try { + expect((await load('competing')).status).toBe(409); + expect(requests).toEqual([]); + } finally { + release(); + } + expect((await pending).status).toBe(200); + expect((await load('other-operation')).status).toBe(409); + await headers(supertest(server).post(`/session/${SESSION_ID}/load`)) + .send(definition) + .expect(409); + await headers(supertest(server).post('/session')) + .send({ + ...definition, + lifecycleAuthority: { operationId: 'create', claimGeneration: 1 }, + }) + .expect(400); + await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .send({ authority: { operationId: 'current', claimGeneration: 1 } }) + .expect(204); + }); + + it.each(['prompt', 'title', 'hooks/operations'])( + 'refuses a late ordinary %s preflight after lifecycle adoption', + async (route) => { + const { server, authorize, definition, requests } = await hookApp(); + const transcriptPath = path.join(state.root, `${SESSION_ID}.jsonl`); + const before = await readFile(transcriptPath, 'utf8'); + let release!: () => void; + state.authorizeOrdinary.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve(undefined); + }), + ); + const prompt = [{ type: 'text', text: 'late request' }]; + const pending = authorize( + supertest(server).post(`/session/${SESSION_ID}/${route}`), + ) + .send( + route === 'title' + ? { title: 'late title' } + : route === 'hooks/operations' + ? { + operationId: 'ea17a7bc-b8d6-4f42-9e82-3859309fe92a', + event: 'Notification', + input: { message: 'late', notification_type: 'test' }, + } + : { + prompt, + promptId: PROMPT_ID, + payloadDigest: `sha256:${createHash('sha256').update(JSON.stringify(prompt)).digest('hex')}`, + }, + ) + .then((response) => response); + await vi.waitFor(() => expect(release).toBeDefined()); + try { + await headers(supertest(server).post(`/session/${SESSION_ID}/load`)) + .send({ + ...definition, + lifecycleAuthority: { operationId: 'adopt', claimGeneration: 1 }, + }) + .expect(200); + } finally { + release(); + } + const refused = await pending; + expect(refused.status).toBe(409); + expect(refused.body.code).toBe('hosted_lifecycle_operation_active'); + expect(requests).toEqual([]); + expect(state.model).not.toHaveBeenCalled(); + expect(await readFile(transcriptPath, 'utf8')).toBe(before); + await authorize(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .send({ authority: { operationId: 'adopt', claimGeneration: 1 } }) + .expect(204); + }, + ); + it.each([true, false])( 'retains the attachment and restores authority when a recovered detach claim is rejected (clientId=%s)', async (withClientId) => { diff --git a/packages/cli/src/serve/hosted-harness-session.ts b/packages/cli/src/serve/hosted-harness-session.ts index 6df81a0897c..333ce4c569f 100644 --- a/packages/cli/src/serve/hosted-harness-session.ts +++ b/packages/cli/src/serve/hosted-harness-session.ts @@ -177,6 +177,7 @@ interface HostedSession { approval?: HostedApprovalSettings; waiters: HostedApprovalWaiters; stores?: HttpManagedSessionStores; + storeDescriptor: ReturnType; lifecycle?: ManagedSessionLifecycleAuthority; lifecycleKind?: 'close' | 'delete'; /** A recovery load acquired the Runtime Session for this promptId. On @@ -1461,6 +1462,15 @@ export function registerHostedHarnessSessionRoutes( error(res, 409, 'hosted_harness_generation_mismatch'); return; } + let lifecycle: ManagedSessionLifecycleAuthority | undefined; + try { + lifecycle = lifecycleAuthority(body?.['lifecycleAuthority']); + if (lifecycle && create) + throw new Error('Lifecycle load cannot create a Session.'); + } catch { + error(res, 400, 'invalid_hosted_lifecycle_authority'); + return; + } // One shape for every successful open/load answer — a takeover load // redriven after a lost reply must be indistinguishable from the answer // it replaces, so all three sites build it here. @@ -1482,6 +1492,42 @@ export function registerHostedHarnessSessionRoutes( : {}), }); const attached = sessions.get(sessionId); + if (attached !== undefined && lifecycle) { + if ( + !isDeepStrictEqual(store, attached.storeDescriptor) || + toolProfile !== HOSTED_WORKSPACE_FILE_PROFILE || + attached.toolProfile !== HOSTED_WORKSPACE_FILE_PROFILE || + (attached.lifecycle && + attached.lifecycle.operationId !== lifecycle.operationId) + ) { + error(res, 409, 'hosted_session_already_attached'); + return; + } + if ( + attached.active || + attached.mcpBusy || + attached.mcpRecovering || + attached.hooksBusy + ) { + error(res, 409, 'hosted_turn_active'); + return; + } + const previous = attached.lifecycle; + attached.hooksBusy = true; + attached.stores!.setLifecycleAuthority(lifecycle); + try { + await attached.stores!.assertWritable(); + attached.lifecycle = lifecycle; + res.status(200).json(attachmentReply(attached)); + } catch (cause) { + attached.stores!.setLifecycleAuthority(previous); + debugLogger.warn('Hosted lifecycle attachment claim rejected:', cause); + error(res, 503, 'managed_session_open_failed'); + } finally { + attached.hooksBusy = false; + } + return; + } if (attached !== undefined && !create) { const passive = body?.['passiveManagedRuntimeRecovery'] === true; if ( @@ -1578,17 +1624,7 @@ export function registerHostedHarnessSessionRoutes( ); return; } - let lifecycle: ManagedSessionLifecycleAuthority | undefined; - try { - lifecycle = lifecycleAuthority(body?.['lifecycleAuthority']); - if (lifecycle) { - if (create) throw new Error('Lifecycle load cannot create a Session.'); - stores.setLifecycleAuthority(lifecycle); - } - } catch { - error(res, 400, 'invalid_hosted_lifecycle_authority'); - return; - } + if (lifecycle) stores.setLifecycleAuthority(lifecycle); opening.add(sessionId); let managed: ManagedSession | undefined; try { @@ -1703,6 +1739,7 @@ export function registerHostedHarnessSessionRoutes( blocked: false, waiters: new HostedApprovalWaiters(), stores, + storeDescriptor: store, lifecycle, ...(toolProfile ? { toolProfile } : {}), ...(toolProfile === HOSTED_WORKSPACE_SHELL_PROFILE && @@ -2143,8 +2180,12 @@ export function registerHostedHarnessSessionRoutes( session?.lifecycle && req.method !== 'GET' && !['/lifecycle', '/detach', '/heartbeat'].includes(req.path) - ) + ) { + const legacyClose = req.method === 'DELETE' && req.path === '/'; + if (!identity(req, sessions, legacyClose)) + return error(res, 404, 'hosted_session_not_found'); return error(res, 409, 'hosted_lifecycle_operation_active'); + } if ( session && req.method !== 'GET' && @@ -2158,6 +2199,8 @@ export function registerHostedHarnessSessionRoutes( await session.stores!.authorizeOrdinary( legacyClose ? 'legacy-close' : undefined, ); + if (session.lifecycle) + return error(res, 409, 'hosted_lifecycle_operation_active'); } catch (cause) { return ordinaryAuthorizationError(res, cause); } diff --git a/packages/core/src/managed-runtime/http-managed-session-store.test.ts b/packages/core/src/managed-runtime/http-managed-session-store.test.ts index 1ef54b75746..8ed6ac4c8f3 100644 --- a/packages/core/src/managed-runtime/http-managed-session-store.test.ts +++ b/packages/core/src/managed-runtime/http-managed-session-store.test.ts @@ -281,6 +281,78 @@ describe('HTTP Managed Session store', () => { } }); + it.each([ + ['ordinary', false], + ['previous', false], + ['same', false], + ['ordinary', true], + ] as const)( + 'rechecks a changed claim after an in-flight %s renewal (refused=%s)', + async (previous, refused) => { + const server = new FakeManagedSessionStore(); + const claims: Array = []; + let release!: () => void; + const barrier = new Promise((resolve) => { + release = resolve; + }); + const stores = createHttpManagedSessionStores({ + baseUrl: 'https://session-store.test', + sessionKey: SESSION_KEY, + writerId: 'harness-a', + writerToken: TOKEN_A, + fetchFn: async (input, init) => { + if (requestUrl(input).endsWith('/writers:renew')) { + const headers = new Headers(init?.headers); + claims.push(headers.get('X-Qwen-Lifecycle-Claim-Generation')); + if (claims.length === 1) await barrier; + else if (refused) + return jsonResponse( + { + error: { + code: 'workspace_lifecycle_claim_fenced', + message: 'The lifecycle claim is fenced.', + }, + }, + 409, + ); + } + return server.fetch(input, init); + }, + }); + try { + await stores.journalStore.open({ sessionKey: SESSION_KEY }); + if (previous !== 'ordinary') + stores.setLifecycleAuthority({ + operationId: 'delete', + claimGeneration: previous === 'same' ? 2 : 1, + }); + const old = stores.assertWritable(); + await vi.waitFor(() => expect(claims).toHaveLength(1)); + stores.setLifecycleAuthority({ + operationId: 'delete', + claimGeneration: 2, + }); + const current = stores.assertWritable(); + release(); + await old; + if (refused) + await expect(current).rejects.toMatchObject({ + status: 409, + remoteCode: 'workspace_lifecycle_claim_fenced', + }); + else await current; + expect(claims).toEqual( + previous === 'same' + ? ['2'] + : [previous === 'ordinary' ? null : '1', '2'], + ); + } finally { + release(); + await stores.close(); + } + }, + ); + it('publishes bounded tool output immediately under the original writer grant', async () => { const server = new FakeManagedSessionStore(); let publication: Record | undefined; diff --git a/packages/core/src/managed-runtime/http-managed-session-store.ts b/packages/core/src/managed-runtime/http-managed-session-store.ts index 0dcd47acdcd..d00446e6504 100644 --- a/packages/core/src/managed-runtime/http-managed-session-store.ts +++ b/packages/core/src/managed-runtime/http-managed-session-store.ts @@ -410,6 +410,7 @@ class ManagedSessionStoreHttpClient { private readonly fetchFn: typeof fetch; private grant: WriterGrant | undefined; private renewPromise: Promise | undefined; + private renewingAuthority?: ManagedSessionLifecycleAuthority; private renewTimer: NodeJS.Timeout | undefined; private sealed = false; private lifecycleAuthority?: ManagedSessionLifecycleAuthority; @@ -971,8 +972,20 @@ class ManagedSessionStoreHttpClient { } private renewWriter(): Promise { - if (this.renewPromise !== undefined) return this.renewPromise; + if (this.renewPromise !== undefined) { + if ( + this.lifecycleAuthority?.operationId === + this.renewingAuthority?.operationId && + this.lifecycleAuthority?.claimGeneration === + this.renewingAuthority?.claimGeneration + ) + return this.renewPromise; + return this.renewPromise.then(() => this.renewWriter()); + } const grant = this.requireGrant(); + this.renewingAuthority = this.lifecycleAuthority + ? { ...this.lifecycleAuthority } + : undefined; const renewal = (async () => { const renewed = parseWriterGrant( await this.json('/writers:renew', 'POST', { diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java index f2665d40698..f165a70dacd 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java @@ -526,6 +526,9 @@ sessionId, commandKeyHash(request.operation(), if (refusal.getStatusCode() == 409 && "workspace_unavailable".equals(refusal.getCode())) { throw WorkspaceLifecycleStore.blocked("workspace_lifecycle_authorization_revoked"); } + if (refusal.getStatusCode() == 409) { + throw new ApiException(HttpStatus.CONFLICT, refusal.getCode(), refusal.getMessage()); + } throw refusal; } } diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java index c0f7882e48b..bfc03fcf15b 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/store/WorkspaceLifecycleStoreTest.java @@ -47,7 +47,7 @@ class WorkspaceLifecycleStoreTest { @ParameterizedTest @MethodSource("fencedHookTransitions") void lifecycleHookTransitionsRecheckNewEffectsAndKeepOriginalSettlement(boolean authority, boolean granted, - String before, String after, boolean replacement, boolean accepted, boolean batch) throws Exception { + String before, String after, boolean replacement, boolean accepted, boolean batch, boolean draining) throws Exception { var fixture = fixture(); var store = new ManagedSessionStore(fixture.jdbc); store.setLifecycleExecution(new WorkspaceExecutionStore(fixture.jdbc, @@ -78,6 +78,7 @@ void lifecycleHookTransitionsRecheckNewEffectsAndKeepOriginalSettlement(boolean journal.committed(request); } var operation = fixture.admit(OperationKind.DELETE); + if (draining) fixture.jdbc.update("UPDATE qwen_runtime_harness_drain SET phase = 'DRAINING'"); fixture.jdbc.update("UPDATE managed_workspace_access SET can_create = ?", granted); ObjectNode next = execution.deepCopy(); hookState(next, after, registration.get("catalogRef")); @@ -116,7 +117,7 @@ void lifecycleHookTransitionsRecheckNewEffectsAndKeepOriginalSettlement(boolean } else { assertThatThrownBy(commit::run).isInstanceOfSatisfying(ApiException.class, error -> { assertThat(error.getStatus()).isEqualTo(HttpStatus.CONFLICT); - assertThat(error.getCode()).isEqualTo(authority && !replacement + assertThat(error.getCode()).isEqualTo(draining ? "workspace_lifecycle_claim_fenced" : authority && !replacement ? "workspace_lifecycle_authorization_revoked" : "workspace_lifecycle_admission_closed"); }); assertThat(fixture.jdbc.queryForObject("SELECT journal_revision FROM qwen_managed_session_journal_head", Long.class)).isEqualTo(revision); @@ -140,8 +141,16 @@ static Stream fencedHookTransitions() { values[6] = false; return Arguments.of(values); }); - return Stream.concat(single, Stream.of(Arguments.of(true, true, "intent", "outcome_unknown", true, false, true), - Arguments.of(true, true, "intent", "outcome_unknown", false, true, true))); + Stream existing = Stream.concat(single, Stream.of(Arguments.of(true, true, "intent", "outcome_unknown", true, false, true), + Arguments.of(true, true, "intent", "outcome_unknown", false, true, true))).map(arguments -> { + var values = java.util.Arrays.copyOf(arguments.get(), 8); + values[7] = false; + return Arguments.of(values); + }); + return Stream.concat(existing, Stream.of( + Arguments.of(true, true, "intent", "dispatch_started", false, false, false, true), + Arguments.of(true, true, "intent", "outcome_unknown", false, false, false, true), + Arguments.of(true, true, "intent", "not_started_proven", false, true, false, true))); } private static void hookState(ObjectNode record, String execution, JsonNode result) { diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java index 9c0b1f3bcb1..d94e8009d6d 100644 --- a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java +++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java @@ -540,18 +540,26 @@ public Map settleLifecycle(HarnessSessionRef session, Map authority) { HarnessSessionRef ref = requireSessionRef(session); + AttachmentState state = attachments.get(ref.getHarnessSessionId()); + ActivePrompt prompt = activePrompts.get(ref.getHarnessSessionId()); HttpSupport.Response response = sendMutation(sessionPath(ref.getHarnessSessionId()) + "/detach", Map.of("authority", authority), ref.getHarnessClientId(), "POST /session/:id/detach"); if (response.getStatusCode() != 404) { requireMutationStatus(response, 204, "POST /session/:id/detach"); } - removeAttachment(ref); + if (state != null && state.matches(ref) && attachments.remove(ref.getHarnessSessionId(), state)) { + state.cancel(); + if (prompt != null) { + activePrompts.remove(ref.getHarnessSessionId(), prompt); + } + } } public void detachLifecycle(String harnessSessionId, Map authority) { ensureOpen(); String sessionId = requireUuid(harnessSessionId, "harnessSessionId"); AttachmentState state = attachments.get(sessionId); + ActivePrompt prompt = activePrompts.get(sessionId); HttpSupport.Response response = sendMutation(sessionPath(sessionId) + "/detach", Map.of("authority", authority), null, "POST /session/:id/detach"); if (response.getStatusCode() != 404) { @@ -559,7 +567,9 @@ public void detachLifecycle(String harnessSessionId, Map authori } if (state != null && attachments.remove(sessionId, state)) { state.cancel(); - activePrompts.remove(sessionId); + if (prompt != null) { + activePrompts.remove(sessionId, prompt); + } } } diff --git a/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java index 81cc0bf1f0a..76b3e4c0ba1 100644 --- a/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java +++ b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java @@ -126,6 +126,84 @@ static Stream lifecycleDetachResponses() { Arguments.of(byId, 500, true), Arguments.of(byId, 404, false))); } + @ParameterizedTest + @MethodSource("confirmedLifecycleDetachResponses") + void lifecycleDetachReleasesOnlyTheOriginalPrompt(boolean byId, int status, boolean replacement) throws Exception { + createSessionRoute(); + CountDownLatch detaching = new CountDownLatch(1); + CountDownLatch reply = new CountDownLatch(1); + server.createContext("/session/" + SESSION_ID + "/detach", exchange -> { + assertPrivateHeaders(exchange, !byId); + detaching.countDown(); + if (replacement) { + try { assertTrue(reply.await(2, TimeUnit.SECONDS)); } + catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new IOException(error); } + } + exchange.getResponseHeaders().set(HostedHarnessClient.BOOT_ID_HEADER, BOOT_ID); + if (status == 204) { + exchange.getResponseHeaders().set("Connection", "close"); + exchange.sendResponseHeaders(204, -1); + exchange.close(); + } else sendJson(exchange, status, "{\"code\":\"session_not_found\"}", false); + }); + server.createContext("/session/" + SESSION_ID + "/load", exchange -> + sendSessionJson(exchange, 200, sessionJson().replace(CLIENT_ID, "replacement-client"))); + AtomicInteger calls = new AtomicInteger(); + server.createContext("/session/" + SESSION_ID + "/prompt", exchange -> { + int call = calls.incrementAndGet(); + assertEquals(call == 1 ? CLIENT_ID : "replacement-client", + exchange.getRequestHeaders().getFirst(HostedHarnessClient.CLIENT_ID_HEADER)); + var request = JsonSupport.parseObject(readBody(exchange), "prompt"); + exchange.getResponseHeaders().set(HostedHarnessClient.BOOT_ID_HEADER, BOOT_ID); + sendJson(exchange, 202, "{\"promptId\":\"" + request.get("promptId") + + "\",\"lastEventId\":0,\"eventEpoch\":\"" + EVENT_EPOCH + "\"}", false); + }); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try (HostedHarnessClient client = newClient()) { + HarnessSessionRef original = createSession(client); + Map block = Map.of("type", "text", "text", "first"); + client.submitTurn(requestForSession(block, original)); + var detach = executor.submit(() -> { + var authority = Map.of("operationId", "delete", "claimGeneration", 1); + if (byId) client.detachLifecycle(SESSION_ID, authority); + else client.detachLifecycle(original, authority); + }); + assertTrue(detaching.await(2, TimeUnit.SECONDS)); + HarnessSessionRef next; + if (!replacement) detach.get(2, TimeUnit.SECONDS); + next = client.loadSession(new LoadHarnessSession(SESSION_ID, + ManagedSessionStoreConnection.builder().baseUri(URI.create("https://store.example/")) + .tenantId("tenant").workspaceId("workspace").writerId(BOOT_ID) + .leaseDuration(Duration.ofSeconds(45)).build())); + if (replacement) { + // A completed old prompt permits the replacement's new prompt before the old detach returns. + server.createContext("/session/" + SESSION_ID + "/status", exchange -> { + exchange.getResponseHeaders().set(HostedHarnessClient.BOOT_ID_HEADER, BOOT_ID); + sendJson(exchange, 200, "{\"sessionId\":\"" + SESSION_ID + "\",\"hasActivePrompt\":false}", false); + }); + client.getStatus(next); + } + var second = SubmitHarnessTurn.builder().session(next).promptId(SECOND_PROMPT_ID).addContent(block) + .payloadDigest(SubmitHarnessTurn.computePayloadDigest(List.of(block))).build(); + client.submitTurn(second); + reply.countDown(); + detach.get(2, TimeUnit.SECONDS); + assertEquals(2, calls.get()); + var third = SubmitHarnessTurn.builder().session(next).promptId(OTHER_BOOT_ID).addContent(block) + .payloadDigest(SubmitHarnessTurn.computePayloadDigest(List.of(block))).build(); + assertThrows(DaemonException.class, () -> client.submitTurn(third)); + assertEquals(2, calls.get()); + } finally { + reply.countDown(); + executor.shutdownNow(); + } + } + + static Stream confirmedLifecycleDetachResponses() { + return Stream.of(true, false).flatMap(byId -> Stream.of(204, 404) + .flatMap(status -> Stream.of(true, false).map(replacement -> Arguments.of(byId, status, replacement)))); + } + @Test void lifecycleSettlementRetainsAttachmentAndDetachCarriesTheNewClaim() { server.removeContext("/capabilities"); diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerService.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerService.java index f5217b03c35..b170083e6e2 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerService.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerService.java @@ -2985,7 +2985,9 @@ private void beginDispatch(SessionContext context, private void beginDispatch(SessionContext context, ToolExecutionRecord prepared, Map payload, RuntimePublicationGrant grant) { - bindingRepository.requireHarnessAdmission(context.session().getScope(), context.session().getHarnessSessionId(), null); + if (!prepared.isCancelRequested()) { + bindingRepository.requireHarnessAdmission(context.session().getScope(), context.session().getHarnessSessionId(), null); + } if (payload == null && ("deferred".equals(prepared.getReference().get("dispatchMode")) || "deferred_v3".equals(prepared.getReference().get("dispatchMode"))) && !prepared.isCancelRequested()) { diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerServiceTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerServiceTest.java index 024d4510a56..f6cb03471af 100644 --- a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerServiceTest.java +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerServiceTest.java @@ -1321,6 +1321,27 @@ void observesTheOriginalOwnerAfterNewAdmissionIsRevoked() { } } + @Test + void legacyDrainSettlesCancelledDispatchWithoutInvokingTheWorker() { + try (Fixture fixture = new Fixture(SESSION_SCOPE)) { + RuntimeSessionRecord session = join(fixture.service.acquire("harness", "runtime", "bootstrap")); + fixture.executionRepository.findOrCreate(ToolExecutionRecord.prepared("parked", "parked-key", + session.getBindingId(), session.getRuntimeGeneration(), "harness", "runtime", "prompt", "call", "digest", + reference("runtime", "digest"))); + fixture.executionRepository.claimDispatch("parked", "broker", Duration.ofMinutes(1)); + fixture.bindingRepository.requestHarnessDrain("tenant", "harness"); + ToolExecutionRecord settled = join(fixture.service.cancelExecution("harness", "runtime", "parked")); + assertEquals(ToolExecutionRecord.State.SETTLED, settled.getState()); + assertEquals("cancelled", settled.getExecutionStatus()); + assertTrue(settled.isCancelRequested()); + assertSame(settled, join(fixture.service.cancelExecution("harness", "runtime", "parked"))); + assertEquals("runtime_admission_closed", failure(fixture.service.createExecution("harness", "runtime", + "new-key", reference("runtime", "other"))).getCode()); + assertEquals(0, fixture.transport.executeCalls.get()); + assertEquals(0, fixture.transport.cancelCalls.get()); + } + } + @Test void settledCancellationWinsOverLateExecutionCompletion() { try (Fixture fixture = new Fixture(WORKSPACE_SCOPE)) { From a85d0828d97632a310ddc83d0b00da1952790871 Mon Sep 17 00:00:00 2001 From: jinye Date: Tue, 6 Oct 2026 02:10:38 +0800 Subject: [PATCH 12/17] fix(managed): restore original lifecycle Hook owner before dispatch --- .../workspace-session-active-delete-l3.md | 18 +- ...orkspace-session-active-delete-l3.zh-CN.md | 14 +- .../cli/src/serve/hosted-hook-session.test.ts | 192 ++++++++++++++++++ packages/cli/src/serve/hosted-hook-session.ts | 10 +- 4 files changed, 227 insertions(+), 7 deletions(-) diff --git a/docs/design/workspace-session-active-delete-l3.md b/docs/design/workspace-session-active-delete-l3.md index cb8d7cf68b9..42d5315362f 100644 --- a/docs/design/workspace-session-active-delete-l3.md +++ b/docs/design/workspace-session-active-delete-l3.md @@ -118,6 +118,12 @@ new dispatch remains excluded. Typed transactional claim refusals retain their only the captured attachment and prompt state; a late response cannot clear a replacement's heartbeat or prompt watermark. +Before a new lifecycle Hook leaves intent, restore its saved original Broker +owner by binding and generation. A surviving Harness attachment cannot infer +that a restarted Broker still has that owner. Failed attestation leaves the +child intent retryable without dispatch; current authority is checked again +before dispatch. Already unknown outcomes remain blocked and are not replayed. + Before each new side-effect dispatch, check current ACL, mount and identity. After revocation, lookup and settlement of already dispatched work continue; undispatched Hooks stay recovery_blocked until authority returns. Unknown effects @@ -198,9 +204,15 @@ Hook control path only while their persisted close claim is valid. Ordinary execution remains fenced; the exception cannot authorize L3 or MCP execution. Legacy claim validity compares database epoch milliseconds on both sides, independent of JVM, JDBC and database session time zones. -Upgrade all -coordinators and Harnesses before enabling new L3 admission. A missing protocol -capability rejects admission rather than falling back to legacy DELETE. Do not +Upgrade every Spring coordinator/Store first, then the Hosted Harnesses. There +is no separate L3 enable switch. A new Harness against an old Store lacking +ordinary execution authorization refuses all hosted Turns, including private +Sessions; it must not treat a missing authorization route as permission. +During the Store-first mixed-version interval, ordinary Turns remain available, +but ACTIVE Workspace close and delete are unavailable until the Harnesses +advertise the new lifecycle capability. Schedule this temporary loss of close +availability as part of the rollout. A missing protocol capability rejects +admission rather than falling back to legacy DELETE. Do not roll back to old coordinators while L3 operations remain unfinished. L2 CLOSED/ARCHIVED deletion remains independent of Harness availability. diff --git a/docs/design/workspace-session-active-delete-l3.zh-CN.md b/docs/design/workspace-session-active-delete-l3.zh-CN.md index cdd86e804a0..7b96a60bc45 100644 --- a/docs/design/workspace-session-active-delete-l3.zh-CN.md +++ b/docs/design/workspace-session-active-delete-l3.zh-CN.md @@ -92,6 +92,11 @@ Plan 身份使用固定的紧凑 JSON 字节,不受应用 JSON 格式化配置 code,并回滚全部 journal mutation。确认 lifecycle detach 后仅删除请求前捕获的 attachment 与 prompt 状态;晚到应答不得清除替代 attachment 的心跳或 prompt 水位。 +新生命周期 Hook 离开 intent 前,凭已保存的原 binding 与 generation 恢复 Broker +owner。存活的 Harness attachment 不能据此推断重启的 Broker 仍持有原 owner。 +原身份核验失败时保留可重试的 child intent,不进行派发;派发前再次核验当前权限。 +已有 unknown 结果继续阻塞,不重放副作用。 + 每次新副作用派发前核对当前 ACL、挂载和身份。撤销后仍可查询、结算已派发工作; 未派发 Hook 保持 recovery_blocked,直至恢复权限。unknown 保留所有权,不能 变成取消或完成证明。沿用 H2 可能无限期阻塞的限制,独立由 #13133 跟踪。 @@ -149,8 +154,13 @@ L3 返回 `workspace_lifecycle_journal_unverified`。未来 compaction 必须 仍存活的 protocol-zero close attachment 仅在持久 close claim 有效时保留原 DELETE 和原 Hook control 路径。普通执行继续被围栏阻止;此例外不能授权 L3 或 MCP 执行。 旧 claim 的有效性统一比较数据库 epoch 毫秒,不受 JVM、JDBC 与数据库会话时区影响。 -启用 L3 准入前升级全部 coordinator 和 Harness。缺少新协议能力时拒绝准入, -不回退到旧 DELETE。存在未完成 L3 操作时不回滚到旧 coordinator。 +先升级全部 Spring coordinator/Store,再升级 Hosted Harness;没有独立的 L3 启用开关。 +新 Harness 对接缺少普通执行授权路由的旧 Store 时,会拒绝全部托管 Turn, +包括私有 Session;缺少授权路由不能当作执行许可。 +先升级 Store 的混合版本阶段,普通 Turn 仍可用,但 ACTIVE Workspace close 和 +delete 暂不可用,直到 Harness 宣告新生命周期能力。部署计划须包含这一暂时的 +close 不可用窗口。缺少新协议能力时拒绝准入,不回退到旧 DELETE。 +存在未完成 L3 操作时不回滚到旧 coordinator。 L2 CLOSED/ARCHIVED 删除继续独立于 Harness 可用性。 读取生命周期或普通 writer 状态前,先执行已配置的 Store writer 凭证校验。 diff --git a/packages/cli/src/serve/hosted-hook-session.test.ts b/packages/cli/src/serve/hosted-hook-session.test.ts index 4637e5be288..fbe1510420e 100644 --- a/packages/cli/src/serve/hosted-hook-session.test.ts +++ b/packages/cli/src/serve/hosted-hook-session.test.ts @@ -181,6 +181,198 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }); }); +it.each([HookEventName.SessionEnd, HookEventName.SessionDelete])( + 'restores the original lifecycle Hook owner before dispatching %s after Broker restart', + async (eventName) => { + let lifecycle = false; + let ownerPresent = true; + hooks = new HostedHookSession( + { + ...options, + lifecycleAuthority: () => + lifecycle + ? { operationId: 'close-original', claimGeneration: 2 } + : undefined, + }, + session, + pin, + ); + catalog = { ...catalog, hooks: [{ ...catalog.hooks[0], eventName }] }; + const acquire = vi.mocked(HostedWorkspaceBroker.prototype.acquire); + const originalAcquire = acquire.getMockImplementation()!; + acquire.mockImplementation(async function ( + this: HostedWorkspaceBroker, + expected, + ) { + if (lifecycle) { + expect(expected).toEqual({ + runtimeBindingId: 'binding', + generation: '1', + }); + expect(hooks.hasPendingOperations).toBe(true); + } + await originalAcquire.call(this, expected); + ownerPresent = true; + }); + vi.spyOn( + HostedWorkspaceBroker.prototype, + 'authorizeLifecycle', + ).mockResolvedValue(); + const control = vi.mocked(HostedWorkspaceBroker.prototype.hookControl); + const originalControl = control.getMockImplementation()!; + control.mockImplementation(async function ( + this: HostedWorkspaceBroker, + operation, + ) { + if (operation.kind === 'hook-execute' && !ownerPresent) + throw new HostedWorkspaceBrokerRejection( + 404, + 'runtime_session_not_found', + ); + return originalControl.call(this, operation); + }); + await hooks.ensureReady(); + const originalRuntimeSessionId = hooks.broker.runtimeSessionId; + ownerPresent = false; + lifecycle = true; + acquire.mockClear(); + const output = await hooks.fire( + eventName, + 'lifecycle-after-restart', + {}, + signal(), + ); + expect(output?.hookSpecificOutput?.['additionalContext']).toBe('checked'); + expect(acquire).toHaveBeenCalledExactlyOnceWith({ + runtimeBindingId: 'binding', + generation: '1', + }); + expect(hooks.broker.runtimeSessionId).toBe(originalRuntimeSessionId); + expect(hooks.broker.runtime).toEqual({ + bindingId: 'binding', + generation: '1', + workspaceGeneration: '1', + }); + expect( + vi.mocked(HostedWorkspaceBroker.prototype.warm), + ).toHaveBeenCalledOnce(); + expect( + await hooks.fire(eventName, 'lifecycle-after-restart', {}, signal()), + ).toEqual(output); + expect( + requests.filter((request) => request.kind === 'hook-execute'), + ).toHaveLength(1); + expect(hooks.hasPendingOperations).toBe(false); + }, +); + +it('keeps a lifecycle Hook intent retryable when its original owner cannot be restored', async () => { + let lifecycle = false; + hooks = new HostedHookSession( + { + ...options, + lifecycleAuthority: () => + lifecycle + ? { operationId: 'close-original', claimGeneration: 2 } + : undefined, + }, + session, + pin, + ); + catalog = { + ...catalog, + hooks: [{ ...catalog.hooks[0], eventName: HookEventName.SessionEnd }], + }; + vi.spyOn( + HostedWorkspaceBroker.prototype, + 'authorizeLifecycle', + ).mockResolvedValue(); + await hooks.ensureReady(); + lifecycle = true; + const refusal = new HostedWorkspaceBrokerRejection( + 409, + 'workspace_close_identity_unverified', + ); + const acquire = vi.mocked(HostedWorkspaceBroker.prototype.acquire); + const originalAcquire = acquire.getMockImplementation()!; + acquire.mockRejectedValue(refusal); + await expect( + hooks.fire(HookEventName.SessionEnd, 'owner-refused', {}, signal()), + ).rejects.toBe(refusal); + const children = session.authority + .extensionRecordsInDomain('hook_execution') + .map((entry) => parseHookExecution(entry.record)) + .filter((record) => record.hookId !== '__plan__'); + expect(children).toHaveLength(1); + expect(children[0].run.execution).toBe('intent'); + expect(children[0].run.reason).toBeNull(); + expect( + requests.filter((request) => request.kind === 'hook-execute'), + ).toHaveLength(0); + acquire.mockImplementation(originalAcquire); + expect( + (await hooks.fire(HookEventName.SessionEnd, 'owner-refused', {}, signal())) + ?.hookSpecificOutput?.['additionalContext'], + ).toBe('checked'); + expect( + requests.filter((request) => request.kind === 'hook-execute'), + ).toHaveLength(1); +}); + +it('preserves an uncertain lifecycle Hook outcome after original-owner restoration', async () => { + let lifecycle = false; + hooks = new HostedHookSession( + { + ...options, + lifecycleAuthority: () => + lifecycle + ? { operationId: 'close-original', claimGeneration: 2 } + : undefined, + }, + session, + pin, + ); + catalog = { + ...catalog, + hooks: [{ ...catalog.hooks[0], eventName: HookEventName.SessionEnd }], + }; + vi.spyOn( + HostedWorkspaceBroker.prototype, + 'authorizeLifecycle', + ).mockResolvedValue(); + await hooks.ensureReady(); + lifecycle = true; + const control = vi.mocked(HostedWorkspaceBroker.prototype.hookControl); + const originalControl = control.getMockImplementation()!; + let dispatched = 0; + control.mockImplementation(async function ( + this: HostedWorkspaceBroker, + operation, + ) { + if (operation.kind === 'hook-execute') { + dispatched++; + throw new TypeError('reply lost after possible worker effect'); + } + return originalControl.call(this, operation); + }); + await expect( + hooks.fire(HookEventName.SessionEnd, 'unknown-original', {}, signal()), + ).rejects.toBeInstanceOf(HostedHookRecoveryRequiredError); + await expect( + hooks.fire(HookEventName.SessionEnd, 'unknown-original', {}, signal()), + ).rejects.toBeInstanceOf(HostedHookRecoveryRequiredError); + const child = session.authority + .extensionRecordsInDomain('hook_execution') + .map((entry) => parseHookExecution(entry.record)) + .find((record) => record.hookId !== '__plan__')!; + expect(child.run).toMatchObject({ + state: 'recovery_blocked', + execution: 'outcome_unknown', + reason: 'outcome_unknown', + }); + expect(dispatched).toBe(1); +}); + it('commits a pinned plan before dispatch and replays the original occurrence after reconstruction', async () => { const output = await hooks.fire( HookEventName.PreToolUse, diff --git a/packages/cli/src/serve/hosted-hook-session.ts b/packages/cli/src/serve/hosted-hook-session.ts index b4cd6569201..564a8f624b6 100644 --- a/packages/cli/src/serve/hosted-hook-session.ts +++ b/packages/cli/src/serve/hosted-hook-session.ts @@ -1116,8 +1116,14 @@ export class HostedHookSession { ); this.recoveredBrokers.set(record.runtimeSessionId, broker); } - if (current && !reacquire) await this.acquire(); - else + if (current && !reacquire) { + await this.acquire(); + if (this.options.lifecycleAuthority?.() && broker.runtime) + await broker.acquire({ + runtimeBindingId: broker.runtime.bindingId, + generation: broker.runtime.generation, + }); + } else await broker.acquire( reacquire && record.run.runtime ? record.run.runtime : undefined, ); From 3649f88c15c944e25a33068d9f9fae89b51993f7 Mon Sep 17 00:00:00 2001 From: jinye Date: Tue, 6 Oct 2026 09:11:41 +0800 Subject: [PATCH 13/17] codex: address PR review feedback (#13354) --- .../workspace-session-active-delete-l3.md | 10 +- ...orkspace-session-active-delete-l3.zh-CN.md | 7 +- .../http-managed-session-store.test.ts | 40 ++- .../http-managed-session-store.ts | 12 +- .../store/ToolPublicationAdmissionStore.java | 1 + .../ToolPublicationLifecycleMySqlIT.java | 230 ++++++++++++++++++ 6 files changed, 281 insertions(+), 19 deletions(-) create mode 100644 packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ToolPublicationLifecycleMySqlIT.java diff --git a/docs/design/workspace-session-active-delete-l3.md b/docs/design/workspace-session-active-delete-l3.md index 42d5315362f..44195f71e54 100644 --- a/docs/design/workspace-session-active-delete-l3.md +++ b/docs/design/workspace-session-active-delete-l3.md @@ -73,7 +73,15 @@ existing lock hierarchy to prevent authorization/admission races. The placement guard precedes retention, Session and journal locks. Checking only the journal head or a missing fence row cannot exclude concurrent fence insertion; writer mutations and ordinary execution authorization must share -admission's lock order. The pre-L3 Store already obtains the publication tenant +admission's lock order. A tool-result receipt takes the placement guard before +its original-result, publication tenant and journal locks, including receipt +replay; its nested journal commit must not acquire placement in reverse order. +An ordinary journal transaction definitively refused with +`workspace_lifecycle_admission_closed` before any uncertain commit response +does not stop the cached Session's writes; current lifecycle authority is still +required for subsequent settlement. An uncertain earlier response retains the +write-failure fence. +The pre-L3 Store already obtains the publication tenant lock through its Session lock helper. L3 adds the placement guard and ordinary authorization request, so its incremental contention and request cost require measurement. Drain lookups and mutations use the existing hashed primary key, diff --git a/docs/design/workspace-session-active-delete-l3.zh-CN.md b/docs/design/workspace-session-active-delete-l3.zh-CN.md index 7b96a60bc45..62619360c9b 100644 --- a/docs/design/workspace-session-active-delete-l3.zh-CN.md +++ b/docs/design/workspace-session-active-delete-l3.zh-CN.md @@ -59,7 +59,12 @@ acquire、工具执行、输入和其他控制 mutation。仅当前 operation placement guard 先于 retention、Session 和 journal 锁。仅检查 journal head 或尚不存在的围栏行不能排除并发插入围栏;writer 变更和普通执行授权必须共用 -准入的锁顺序。L3 之前的 Store 已经通过 Session 锁 helper 获取 publication +准入的锁顺序。工具结果 receipt 在原结果、publication 租户和 journal 锁之前 +获取 placement guard,receipt 重放也遵循此顺序,避免嵌套 journal commit +反向获取 placement 锁。普通 journal 事务在尚无不确定提交响应前收到明确的 +`workspace_lifecycle_admission_closed` 回滚拒绝时,不会使缓存 Session 永久停写; +后续结算仍须有效的当前 lifecycle authority。若此前已有不确定响应,仍保留 +写失败围栏。L3 之前的 Store 已经通过 Session 锁 helper 获取 publication 租户锁;新增的是 placement guard 和普通授权请求,其增量竞争和请求开销需要 测量。围栏查询和变更使用既有哈希主键,同时保留原身份检查,避免锁定读取扫描 其他租户的围栏;键编码与 Broker 共用,不新增索引。 diff --git a/packages/core/src/managed-runtime/http-managed-session-store.test.ts b/packages/core/src/managed-runtime/http-managed-session-store.test.ts index 4b98511eb39..1b6a2ec6160 100644 --- a/packages/core/src/managed-runtime/http-managed-session-store.test.ts +++ b/packages/core/src/managed-runtime/http-managed-session-store.test.ts @@ -654,6 +654,8 @@ describe('HTTP Managed Session store', () => { 'lifecycle-403', 'lifecycle-409', 'uncertain-lifecycle-403', + 'ordinary-lifecycle-409', + 'uncertain-ordinary-lifecycle-409', ])( 'preserves activation transaction identity and failure fencing after %s', async (failure) => { @@ -668,7 +670,13 @@ describe('HTTP Managed Session store', () => { failure === 'lost-commit-response' || failure === 'lost-response-body'; const refused = - failure === 'lifecycle-403' || failure === 'lifecycle-409'; + failure === 'lifecycle-403' || + failure === 'lifecycle-409' || + failure === 'ordinary-lifecycle-409'; + const ordinaryLifecycle = failure.includes('ordinary-lifecycle-409'); + const uncertainLifecycle = + failure === 'uncertain-lifecycle-403' || + failure === 'uncertain-ordinary-lifecycle-409'; let armed = false; let replay: unknown; const stores = createHttpManagedSessionStores({ @@ -684,20 +692,27 @@ describe('HTTP Managed Session store', () => { >; if (body['operation'] === 'installActivation') { requests.push(String(init?.body)); - if (refused || failure === 'uncertain-lifecycle-403') + if (ordinaryLifecycle) + expect( + new Headers(init?.headers).has( + 'X-Qwen-Lifecycle-Operation-Id', + ), + ).toBe(false); + if (refused || uncertainLifecycle) return jsonResponse( { error: { message: 'Lifecycle authorization revoked', - code: - failure === 'lifecycle-409' + code: ordinaryLifecycle + ? 'workspace_lifecycle_admission_closed' + : failure === 'lifecycle-409' ? 'workspace_lifecycle_authorization_revoked' : 'workspace_access_denied', }, }, - failure === 'uncertain-lifecycle-403' && requests.length === 1 + uncertainLifecycle && requests.length === 1 ? 503 - : failure === 'lifecycle-409' + : ordinaryLifecycle || failure === 'lifecycle-409' ? 409 : 403, ); @@ -765,7 +780,7 @@ describe('HTTP Managed Session store', () => { create: { definitionRef, rootSnapshotRef, createdBy: 'test' }, }); try { - if (refused || failure === 'uncertain-lifecycle-403') + if ((refused || uncertainLifecycle) && !ordinaryLifecycle) stores.setLifecycleAuthority({ operationId: 'lifecycle', claimGeneration: 1, @@ -817,6 +832,11 @@ describe('HTTP Managed Session store', () => { }); expect(session.authority.writesStopped).toBe(false); armed = false; + if (ordinaryLifecycle) + stores.setLifecycleAuthority({ + operationId: 'lifecycle', + claimGeneration: 1, + }); await expect( controller.runHookOperation( { @@ -832,11 +852,7 @@ describe('HTTP Managed Session store', () => { await expect(operation).rejects.toThrow('writes stopped'); expect(run).not.toHaveBeenCalled(); expect(requests).toHaveLength( - failure === 'exhausted-503' - ? 3 - : failure === 'uncertain-lifecycle-403' - ? 2 - : 1, + failure === 'exhausted-503' ? 3 : uncertainLifecycle ? 2 : 1, ); expect(session.authority.writesStopped).toBe(true); await expect(controller.runTurn('next-turn', run)).rejects.toThrow( diff --git a/packages/core/src/managed-runtime/http-managed-session-store.ts b/packages/core/src/managed-runtime/http-managed-session-store.ts index b44a073cc14..f4d0b17f13e 100644 --- a/packages/core/src/managed-runtime/http-managed-session-store.ts +++ b/packages/core/src/managed-runtime/http-managed-session-store.ts @@ -836,12 +836,14 @@ class ManagedSessionStoreHttpClient { } catch (error) { if ( attempt === 0 && - this.lifecycleAuthority && error instanceof ManagedSessionStoreHttpError && - (error.status === 403 || - (error.status === 409 && - (error.remoteCode.startsWith('workspace_lifecycle_') || - error.remoteCode === 'workspace_unavailable'))) + ((error.status === 409 && + error.remoteCode === 'workspace_lifecycle_admission_closed') || + (this.lifecycleAuthority && + (error.status === 403 || + (error.status === 409 && + (error.remoteCode.startsWith('workspace_lifecycle_') || + error.remoteCode === 'workspace_unavailable'))))) ) throw new ManagedSessionCommitRejectedError(error); const uncertain = diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ToolPublicationAdmissionStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ToolPublicationAdmissionStore.java index fed87af8583..0f236a0738b 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ToolPublicationAdmissionStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ToolPublicationAdmissionStore.java @@ -351,6 +351,7 @@ public JsonNode commitReceipt(JsonNode key, String publicationId, String writerT "Original manifest resource conflicts"); } return transactions.execute(status -> { + WorkspaceLifecycleStore.lockPlacement(jdbc, text(key, "tenantId")); data.lockOriginalSettledResult(key, publicationId, finished); lockTenant(key); sessions.lockPublicationWriter(text(key, "tenantId"), text(key, "workspaceId"), diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ToolPublicationLifecycleMySqlIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ToolPublicationLifecycleMySqlIT.java new file mode 100644 index 00000000000..faf297df042 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ToolPublicationLifecycleMySqlIT.java @@ -0,0 +1,230 @@ +package com.alibaba.qwen.code.managedagent; + +import static com.alibaba.qwen.code.managedagent.PublicationJournalFixture.PUBLICATION_TOKEN; +import static com.alibaba.qwen.code.managedagent.PublicationJournalFixture.WRITER_TOKEN; +import static com.alibaba.qwen.code.managedagent.PublicationJournalFixture.digest; +import static org.assertj.core.api.Assertions.assertThat; + +import com.alibaba.qwen.code.managedagent.api.WorkspaceSelection; +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.CommitResource; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.CommitTransactionRequest; +import com.alibaba.qwen.code.managedagent.store.ManagedWorkspaceRegistry; +import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationKind; +import com.alibaba.qwen.code.managedagent.store.ToolPublicationAdmissionStore; +import com.alibaba.qwen.code.managedagent.store.ToolPublicationDataStore; +import com.alibaba.qwen.code.managedagent.store.ToolPublicationObjectStore; +import com.alibaba.qwen.code.managedagent.store.WorkspaceLifecycleStore; +import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRepository; +import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.time.Clock; +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import javax.sql.DataSource; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DelegatingDataSource; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.support.TransactionTemplate; + +class ToolPublicationLifecycleMySqlIT { + private static final ObjectMapper JSON = new ObjectMapper(); + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void receiptAndReplayDoNotDeadlockWithSameTenantLifecycleCompletion(boolean replay) throws Exception { + String url = System.getProperty("mysql.url"); + if (url == null || !url.matches("jdbc:mysql://[^/]+/[^?]+(?:\\?.*)?")) { + throw new IllegalArgumentException("A MySQL test database URL is required"); + } + String user = System.getProperty("mysql.user"); + String password = System.getProperty("mysql.password", ""); + var admin = new JdbcTemplate(new DriverManagerDataSource(url, user, password)); + String schema = "publication_lifecycle_" + UUID.randomUUID().toString().replace("-", ""); + admin.execute("CREATE DATABASE " + schema); + try { + var source = new ScheduledDataSource(new DriverManagerDataSource( + url.replaceFirst("/[^/?]+(?=\\?|$)", "/" + schema), user, password)); + Flyway.configure().dataSource(source).load().migrate(); + var journal = PublicationJournalFixture.create(source, false); + journal.reserve(); + var jdbc = journal.jdbc; + var transaction = new TransactionTemplate(new DataSourceTransactionManager(source)); + ToolPublicationObjectStore objects = new ToolPublicationObjectStore() { + @Override + public void putIfAbsent(String key, byte[] bytes) { + throw new AssertionError("Inline fixture expected"); + } + @Override + public InputStream open(String key) { + throw new AssertionError("Inline fixture expected"); + } + @Override + public void requireUnversioned() {} + }; + var data = new ToolPublicationDataStore(jdbc, journal.manager, journal.store, journal.sessions, + objects, Duration.ofMinutes(2), Duration.ofSeconds(30), + new ToolPublicationDataStore.VerificationBudget(16 * 1024 * 1024, Duration.ofMinutes(25))); + var key = journal.binding.get("sessionKey"); + var envelope = JSON.createObjectNode().put("executionStatus", "error"); + envelope.putArray("responseParts"); + envelope.set("capture", JSON.createObjectNode().put("captureStatus", "unavailable") + .put("captureReason", "storage_failed").put("previewTruncated", false) + .put("deliveryStatus", "pending").putNull("manifest")); + data.finish(key, "pub-1", PUBLICATION_TOKEN, "finish", envelope.toString().getBytes(StandardCharsets.UTF_8)); + var outcome = JSON.createObjectNode().put("schemaVersion", 1).put("decision", "blocked").putNull("manifestRef"); + outcome.set("envelope", envelope); + var history = JSON.createObjectNode().put("messageId", "11111111-1111-4111-8111-111111111111") + .put("timestamp", "2026-10-06T00:00:00Z").put("model", "test"); + history.putArray("parts").addObject().put("text", "Shell capture unavailable"); + outcome.set("history", history); + var root = data.prepareAdmission(key, "pub-1", "writer-1", 1, WRITER_TOKEN, outcome); + long sequence = journal.sequence + 1; + var payload = JSON.createObjectNode().put("executionCallId", "execution-1") + .put("historyRevision", sequence).putNull("resultRef"); + payload.set("toolOutcomeRef", root); + payload.putArray("resources"); + String records = journal.event(sequence, "tool.receipt", payload) + "{}\n"; + var request = new CommitTransactionRequest("workspace-1", "writer-1", 1, journal.revision, + journal.sequence, "receipt", "recordToolResult", "execution-1", root.path("digest").asText(), + sequence, sequence, 1, digest(records), journal.commitDigest, digest(records), 1, null, 2, + Base64.getEncoder().encodeToString(records.getBytes(StandardCharsets.UTF_8)), digest(records), + List.of(new CommitResource(root.path("resourceId").asText(), "managed-tool-outcome", 1, + root.path("byteLength").asLong(), root.path("digest").asText(), null))); + var admission = new ToolPublicationAdmissionStore(jdbc, journal.manager, journal.sessions, data); + if (replay) { + admission.commitReceipt(key, "pub-1", WRITER_TOKEN, request); + } + + var properties = new ManagedAgentProperties(); + properties.getHarness().setWorkspaceFilesEnabled(true); + var agents = new ManagedAgentStore(jdbc, JSON, Clock.systemUTC(), ignored -> {}, + new ManagedWorkspaceRegistry(jdbc), properties); + var beans = new DefaultListableBeanFactory(); + beans.registerSingleton("bindings", journal.bindings); + var lifecycle = new WorkspaceLifecycleStore(jdbc, JSON, beans.getBeanProvider(RuntimeBindingRepository.class)); + agents.setWorkspaceLifecycleStore(lifecycle); + jdbc.update("INSERT INTO managed_workspace_registry (tenant_id, workspace_id, workspace_generation," + + " storage_id, display_name, config_ref, policy_ref, state)" + + " VALUES ('tenant-1', 'neighbor', 1, 'storage', 'Neighbor', ?, ?, 'ACTIVE')", + WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF); + jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)" + + " VALUES ('tenant-1', 'neighbor', ?, TRUE, TRUE)", "owner".getBytes(StandardCharsets.UTF_8)); + String session = transaction.execute(ignored -> agents.insertWorkspaceSessionCommand("tenant-1", "owner", + "create", "digest", "qwen-code", null, null, List.of(), null, + new WorkspaceSelection("neighbor", ".")).sessionId()); + String operationId = transaction.execute(ignored -> agents.beginWorkspaceLifecycle("tenant-1", session, + OperationKind.CLOSE, "owner", "a".repeat(64), "close", "digest", true, 1).operation().operationId()); + var operation = transaction.execute(ignored -> agents.claimOperation("tenant-1", session, operationId, + "worker", Duration.ofMinutes(5)).orElseThrow()); + assertThat(transaction.execute(ignored -> lifecycle.recoverEffects(operation)).path("neverInitialized").asBoolean()).isTrue(); + source.enabled = true; + try (var workers = Executors.newFixedThreadPool(2)) { + var completion = workers.submit(() -> { + Thread.currentThread().setName("lifecycle"); + return transaction.execute(ignored -> agents.completeOperation("tenant-1", session, operationId, + "worker", operation.claimGeneration(), true)); + }); + try { + assertThat(source.lifecyclePlacement.await(10, TimeUnit.SECONDS)).isTrue(); + var receipt = workers.submit(() -> { + Thread.currentThread().setName("receipt"); + return admission.commitReceipt(key, "pub-1", WRITER_TOKEN, request); + }); + assertThat(source.receiptPlacement.await(10, TimeUnit.SECONDS)).isTrue(); + source.releaseLifecycle.countDown(); + assertThat(completion.get(10, TimeUnit.SECONDS)).isTrue(); + assertThat(receipt.get(10, TimeUnit.SECONDS).path("historyRevision").asLong()).isEqualTo(sequence); + } finally { + source.releaseLifecycle.countDown(); + } + } + source.enabled = false; + assertThat(agents.requireSession("tenant-1", session).status()).isEqualTo("CLOSED"); + assertThat(agents.findOperation("tenant-1", session, operationId).orElseThrow().deliveryState()).isEqualTo("CONFIRMED"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM qwen_managed_session_journal_tx" + + " WHERE tenant_id = 'tenant-1' AND session_id = 'session-1' AND operation = 'recordToolResult'", Integer.class)).isOne(); + assertThat(admission.commitReceipt(key, "pub-1", WRITER_TOKEN, request).path("historyRevision").asLong()).isEqualTo(sequence); + } finally { + admin.execute("DROP DATABASE " + schema); + } + } + + private static final class ScheduledDataSource extends DelegatingDataSource { + private final CountDownLatch lifecyclePlacement = new CountDownLatch(1); + private final CountDownLatch receiptPlacement = new CountDownLatch(1); + private final CountDownLatch releaseLifecycle = new CountDownLatch(1); + private final AtomicBoolean paused = new AtomicBoolean(); + private volatile boolean enabled; + + private ScheduledDataSource(DataSource source) { + super(source); + } + + @Override + public Connection getConnection() throws SQLException { + return wrap(super.getConnection()); + } + + @Override + public Connection getConnection(String user, String password) throws SQLException { + return wrap(super.getConnection(user, password)); + } + + private Connection wrap(Connection connection) { + return (Connection) Proxy.newProxyInstance(getClass().getClassLoader(), new Class[] {Connection.class}, + (proxy, method, args) -> { + try { + Object result = method.invoke(connection, args); + if (!method.getName().equals("prepareStatement") || !(result instanceof PreparedStatement statement)) { + return result; + } + String sql = (String) args[0]; + return Proxy.newProxyInstance(getClass().getClassLoader(), new Class[] {PreparedStatement.class}, + (prepared, call, values) -> { + boolean execute = enabled && call.getName().startsWith("execute"); + String role = Thread.currentThread().getName(); + if (execute && "receipt".equals(role) && sql.startsWith("INSERT INTO qwen_runtime_placement_guard")) { + receiptPlacement.countDown(); + } + try { + Object value = call.invoke(statement, values); + if (execute && "lifecycle".equals(role) + && sql.startsWith("SELECT tenant_id FROM qwen_runtime_placement_guard") + && paused.compareAndSet(false, true)) { + lifecyclePlacement.countDown(); + if (!releaseLifecycle.await(10, TimeUnit.SECONDS)) { + throw new AssertionError("Lifecycle hold timed out"); + } + } + return value; + } catch (InvocationTargetException error) { + throw error.getCause(); + } + }); + } catch (InvocationTargetException error) { + throw error.getCause(); + } + }); + } + } +} From e79206002afa0473c7aea85445fed674b902d2c9 Mon Sep 17 00:00:00 2001 From: jinye Date: Wed, 7 Oct 2026 11:02:53 +0800 Subject: [PATCH 14/17] test(managed-agent): repair lifecycle receipt journal fixture --- .../code/managedagent/ToolPublicationLifecycleMySqlIT.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ToolPublicationLifecycleMySqlIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ToolPublicationLifecycleMySqlIT.java index faf297df042..becbe043f57 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ToolPublicationLifecycleMySqlIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ToolPublicationLifecycleMySqlIT.java @@ -1,5 +1,6 @@ package com.alibaba.qwen.code.managedagent; +import static com.alibaba.qwen.code.managedagent.PublicationJournalFixture.COMMIT_MARKER; import static com.alibaba.qwen.code.managedagent.PublicationJournalFixture.PUBLICATION_TOKEN; import static com.alibaba.qwen.code.managedagent.PublicationJournalFixture.WRITER_TOKEN; import static com.alibaba.qwen.code.managedagent.PublicationJournalFixture.digest; @@ -103,7 +104,7 @@ public void requireUnversioned() {} .put("historyRevision", sequence).putNull("resultRef"); payload.set("toolOutcomeRef", root); payload.putArray("resources"); - String records = journal.event(sequence, "tool.receipt", payload) + "{}\n"; + String records = journal.event(sequence, "tool.receipt", payload) + COMMIT_MARKER; var request = new CommitTransactionRequest("workspace-1", "writer-1", 1, journal.revision, journal.sequence, "receipt", "recordToolResult", "execution-1", root.path("digest").asText(), sequence, sequence, 1, digest(records), journal.commitDigest, digest(records), 1, null, 2, From a98d5342fdadb1a82f7a83a1a458152a9550c96e Mon Sep 17 00:00:00 2001 From: jinye Date: Thu, 8 Oct 2026 00:09:49 +0800 Subject: [PATCH 15/17] fix(managed-agent): fail closed on stale lifecycle capability client --- .../harness/QwenHostedHarnessConnector.java | 3 ++- .../QwenHostedHarnessConnectorTest.java | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java index cda3159155c..7e20b555590 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java @@ -130,7 +130,8 @@ public boolean supportsLifecycle() { } try { return client().capabilities().getLifecycleProtocolVersion() == 1; - } catch (com.alibaba.qwen.code.daemon.DaemonException unavailable) { + } catch (com.alibaba.qwen.code.daemon.DaemonException + | IllegalStateException unavailable) { return false; } } diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java index 206d04c2edf..03008493ee2 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java @@ -69,6 +69,29 @@ class QwenHostedHarnessConnectorTest { private static final String SUBMIT_DIGEST = SubmitHarnessTurn.computePayloadDigest(SUBMIT_CONTENT); + @Test + void lifecycleCapabilityFailsClosedWhenTheCachedClientWasClosed() { + HostedHarnessClient client = mock(HostedHarnessClient.class); + HostedHarnessCapabilities unsupported = mock(HostedHarnessCapabilities.class); + HostedHarnessCapabilities supported = mock(HostedHarnessCapabilities.class); + when(supported.getLifecycleProtocolVersion()).thenReturn(1); + when(client.capabilities()).thenReturn(unsupported, supported) + .thenThrow(new IllegalStateException("HostedHarnessClient is closed")); + ManagedAgentProperties properties = properties(); + properties.getHarness().setWorkspaceFilesEnabled(true); + AgentStateStore sessions = mock(AgentStateStore.class); + WorkspaceExecutionStore execution = mock(WorkspaceExecutionStore.class); + QwenHostedHarnessConnector connector = + new QwenHostedHarnessConnector(properties, sessions, execution); + ReflectionTestUtils.setField(connector, "client", client); + + assertThat(connector.supportsLifecycle()).isFalse(); + assertThat(connector.supportsLifecycle()).isTrue(); + assertThat(connector.supportsLifecycle()).isFalse(); + verify(client, times(3)).capabilities(); + verifyNoInteractions(sessions, execution); + } + @Test void successorDetachesTheOriginalSessionWithoutLoadingAnAttachment() { HostedHarnessClient client = mock(HostedHarnessClient.class); From df1ecdc95f9ceb2f8c19dff0404ed894b1d68542 Mon Sep 17 00:00:00 2001 From: jinye Date: Thu, 8 Oct 2026 01:08:04 +0800 Subject: [PATCH 16/17] fix(managed-agent): align native lifecycle test contracts --- .../managedagent/ManagedAgentMySqlIT.java | 33 ++++++++++++++----- .../WorkspaceSessionRetentionMySqlIT.java | 6 ++-- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentMySqlIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentMySqlIT.java index cc9f86973b9..5f2afd256c2 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentMySqlIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentMySqlIT.java @@ -858,7 +858,7 @@ void journalsNothingAfterTheSessionIsDeleted() @Test @Order(9) - void journalsNothingWhenTheDeletionCommitsMidCommit() throws Exception { + void serializesDeletionBehindTheRecordCommit() throws Exception { DriverManagerDataSource dataSource = dataSource(); Flyway.configure().dataSource(dataSource) .locations("classpath:db/migration").load().migrate(); @@ -886,8 +886,8 @@ void journalsNothingWhenTheDeletionCommitsMidCommit() throws Exception { assertThat(count(jdbc, "qwen_managed_session_task_journal", tenant, session)).isEqualTo(1); // A second connection holds the record row, so the next commit - // blocks at its UPDATE: after its first consistent read took the - // snapshot, and before the deletion guard runs. + // blocks at its UPDATE while retaining the placement guard that + // deletion admission also needs. try (java.sql.Connection holder = dataSource.getConnection()) { holder.setAutoCommit(false); try (var lock = holder.prepareStatement("SELECT record_key FROM" @@ -921,19 +921,36 @@ void journalsNothingWhenTheDeletionCommitsMidCommit() throws Exception { assertThat(System.nanoTime()).isLessThan(deadline); Thread.sleep(50); } - // The deletion begins and commits while the record commit waits - // behind its snapshot. - agents.beginOperation(tenant, session, OperationKind.DELETE, - "sha256:" + "e".repeat(64), "delete-mid", "digest-mid"); + CompletableFuture deletion = CompletableFuture.supplyAsync( + () -> inTransaction(transactions, + () -> agents.beginOperation(tenant, session, + OperationKind.DELETE, + "sha256:" + "e".repeat(64), "delete-mid", + "digest-mid"))); + String admissionBlocked = "SELECT COUNT(*) FROM" + + " information_schema.PROCESSLIST WHERE DB = DATABASE()" + + " AND COMMAND = 'Query' AND TIME >= 1 AND INFO LIKE" + + " 'INSERT INTO qwen_runtime_placement_guard%'"; + while (jdbc.queryForObject(admissionBlocked, Integer.class) == 0) { + assertThat(deletion).as("deletion admission must wait for" + + " the record commit's placement guard").isNotDone(); + assertThat(commit).isNotDone(); + assertThat(System.nanoTime()).isLessThan(deadline); + Thread.sleep(50); + } + assertThat(deletion).isNotDone(); holder.commit(); commit.get(30, TimeUnit.SECONDS); + deletion.get(30, TimeUnit.SECONDS); } assertThat(jdbc.queryForObject("SELECT revision FROM" + " qwen_managed_session_extension_record WHERE" + " tenant_id = ? AND session_id = ?", Long.class, tenant, session)).isEqualTo(2L); assertThat(count(jdbc, "qwen_managed_session_task_journal", tenant, - session)).isEqualTo(1); + session)).isEqualTo(2); + assertThat(agents.requireSession(tenant, session).status()) + .isEqualTo("DELETING"); } @Test diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionRetentionMySqlIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionRetentionMySqlIT.java index efb906db6f1..4ecda372062 100644 --- a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionRetentionMySqlIT.java +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionRetentionMySqlIT.java @@ -351,10 +351,12 @@ void prerequisiteMigrationsPreserveExistingCloseEvidenceAndAllowRetirement() { assertThat(jdbc.queryForMap("SELECT * FROM managed_agent_session")).containsAllEntriesOf(originalSession); assertThat(jdbc.queryForMap("SELECT * FROM managed_agent_operation")) .containsAllEntriesOf(originalClose) - .hasSize(originalClose.size() + 3) + .hasSize(originalClose.size() + 5) .containsEntry("target_cwd_relative", null) .containsEntry("expected_context_revision", null) - .containsEntry("result_context_revision", null); + .containsEntry("result_context_revision", null) + .containsEntry("lifecycle_protocol_version", 0) + .containsEntry("lifecycle_effects_receipt_json", null); var properties = new ManagedAgentProperties(); properties.getHarness().setWorkspaceFilesEnabled(true); var store = new ManagedAgentStore(jdbc, mapper, Clock.systemUTC(), ignored -> {}, new ManagedWorkspaceRegistry(jdbc), properties); From e01b28301af6e9af836be24b309ab3b197a487b2 Mon Sep 17 00:00:00 2001 From: jinye Date: Thu, 8 Oct 2026 08:29:48 +0800 Subject: [PATCH 17/17] codex: address PR review feedback (#13354) --- .../JdbcRuntimeBindingRepository.java | 4 +++ .../RuntimeHarnessDrainTest.java | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/JdbcRuntimeBindingRepository.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/JdbcRuntimeBindingRepository.java index eb574ad9205..8a72047dc21 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/JdbcRuntimeBindingRepository.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/JdbcRuntimeBindingRepository.java @@ -206,6 +206,10 @@ public RuntimeSessionRecord admitSession(RuntimeSessionRepository sessions, RuntimeBindingRecord binding = selectById(connection, candidate.getBindingId(), true); if (binding != null) { + if (binding.getRequest().getStorageId() != null && storageFence(connection, + binding.getRequest().getScope().getTenantId(), binding.getRequest().getStorageId()) != null) { + throw new RuntimeBrokerException(409, "workspace_migrating", "Storage is under maintenance.", false); + } requireHarnessAdmission(connection, candidate.getSession().getScope(), candidate.getSession().getHarnessSessionId(), candidate.getSession().getScope().getLifecycleAuthority()); } diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeHarnessDrainTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeHarnessDrainTest.java index 3d3aafac1b3..fa27facb296 100644 --- a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeHarnessDrainTest.java +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeHarnessDrainTest.java @@ -34,6 +34,32 @@ class RuntimeHarnessDrainTest { private final Provider provisioner = new Provider(); private final Transport transport = new Transport(); + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void storageFenceBlocksLateSessionAdmissionAfterBindingReady(boolean jdbc) { + RuntimeBindingRepository registry = jdbc ? bindings : new InMemoryRuntimeBindingRepository(); + RuntimeSessionRepository sessionRepository = jdbc ? sessions : new InMemoryRuntimeSessionRepository(); + var binding = ready(registry); + var original = candidate(binding, "original"); + registry.admitSession(sessionRepository, original); + registry.requestStorageFence("tenant", "storage", "migration"); + + var failure = assertThrows(RuntimeBrokerException.class, + () -> registry.admitSession(sessionRepository, candidate(binding, "late"))); + assertEquals(409, failure.getStatusCode()); + assertEquals("workspace_migrating", failure.getCode()); + assertFalse(failure.isRetryable()); + assertNull(sessionRepository.findById(scope, "late")); + var retained = sessionRepository.findById(scope, "original"); + assertTrue(original.sameIdentity(retained)); + assertEquals(RuntimeSessionRecord.State.ACQUIRING, retained.getState()); + assertEquals(1, sessionRepository.countActiveByBinding(binding.getBindingId(), binding.getGeneration())); + assertEquals(RuntimeBindingRecord.State.READY, registry.findById(binding.getBindingId()).getState()); + assertEquals(binding.getGeneration(), registry.findById(binding.getBindingId()).getGeneration()); + assertTrue(registry.isStorageFenced("tenant", "storage", "migration")); + assertFalse(registry.isHarnessDraining("tenant", "harness")); + } + @Test void fenceSurvivesRestartAndBlocksLateWarmAndAdmissions() throws Exception { var binding = ready();