Skip to content

feat(managed-agent): Serve durable permission Actions (D6b) - #13101

Merged
yiliang114 merged 7 commits into
mainfrom
codex/12867-d6b-actions
Sep 30, 2026
Merged

yiliang114 merged 7 commits into
mainfrom
codex/12867-d6b-actions

Conversation

@wenshao

@wenshao wenshao commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

Implements D6b permission Actions on the public API and WebShell: list pending approvals, inspect any approval, and submit an owner response as a durable operation. Both surfaces share creator authorization, idempotency, and retries; completion follows the committed Hosted decision, including reconciliation after a lost reply. Workspace sessions can use default or auto-edit approval mode, with a pinned mode and a bounded deployment timeout.

Why it's needed

D6a can pause Hosted tool calls for approval, but the Java server had no way for a session owner to answer them. D6b completes that path so an authorized response lets the turn continue while other actors receive 403 and duplicate responses return the original operation.

Reviewer Test Plan

How to verify

Create a Workspace session with default approval mode and request a file write. Confirm that its pending approval is available through both surfaces and that the session reports Actions support. A reader who did not create the session must receive action_forbidden when answering. The creator's allow response should complete with a decided receipt and let the tool turn finish. Repeating the same response key, including through the other surface, should return the same operation; changing its content must conflict. Verify terminal detail remains readable, terminal approvals disappear from the pending list, and temporary delivery failures retry without changing session lifecycle state.

Evidence (Before & After)

Before: the six Java Action operations were planned and Workspace file execution required yolo. After: 40 focused Java tests passed, including a packaged Hosted Harness and real Runtime worker exercise covering approvals on both surfaces, reader denial, owner admission, replay, decided outcome and completed turns. Eight focused Action tests cover malformed journals, original decision validation, and cross-surface pagination. Build, typecheck, bundle and Checkstyle passed. Earlier isolated MariaDB compatibility verification passed six Action tests and the Hosted process test; final journal validation changes were verified on H2.

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

Environment (optional)

Java 21, Node.js, H2 in MySQL mode, and an isolated MariaDB 10.11.19 instance using the MySQL JDBC driver. The MariaDB process and temporary data directory were removed after testing.

Risk & Scope

  • Main risk or tradeoff: response delivery relies on the journal projection and lease fencing; a recovery-blocked session keeps its response running until committed evidence proves the outcome.
  • Not validated / out of scope: question Actions, votes, the WebShell approval UI, restart recovery of pending approvals, Oracle MySQL, and real-process deny/auto-edit variants.
  • Breaking changes / migration notes: Flyway V24 adds the Action projection and response operation metadata and defaults existing sessions to yolo. Tool publication uses V20–V22; managed MCP records use V23. Default and auto-edit require a Harness that confirms the pinned approval mode.

The English design and Chinese design are synchronized.

Linked Issues

Implements the D6b slice of #12867. Question Actions and votes remain planned.

中文说明

本 PR 的改动

在公共 API 与 WebShell 实现 D6b 权限类 Action:列出待审批请求、读取任意状态的审批,并将 owner 的回答受理为持久 operation。两个入口共享创建者授权、幂等与重试;完成结果以已提交的 Hosted 决定为准,也能在回答丢失后对账。Workspace Session 可使用 default 或 auto-edit 审批模式,模式在创建时固定,部署级超时有明确范围。

为什么需要

D6a 能暂停 Hosted 工具调用并等待审批,但 Java 服务端没有入口供 Session owner 回答。D6b 补齐这条链路,让有权的回答继续 Turn,其他 actor 得到 403,重复回答返回原 operation。

Reviewer Test Plan

如何验证

使用 default 审批模式创建 Workspace Session 并请求写入文件。确认两个入口都能读取待审批 Action,Session 也报告 Actions 能力。非创建者的 reader 回答时必须得到 action_forbidden。创建者回答 allow 后,operation 应携带 decided 回执完成,工具 Turn 随后完成。使用同一回答键重复请求,包括切换入口,应返回相同 operation;修改内容必须冲突。确认终态详情仍可读取,终态审批从待审批列表消失,临时投递失败持续重试且不改变 Session 生命周期状态。

前后证据

之前:六个 Java Action 操作均为 planned,Workspace 文件执行要求 yolo。之后:40 个针对性的 Java 测试通过,包括使用打包 Hosted Harness 与真实 Runtime worker 的测试,覆盖双入口审批、reader 拒绝、owner 受理、重放、decided 结果及 Turn 完成。8 个 Action 测试覆盖畸形 journal、原始决定校验与跨入口分页。构建、typecheck、bundle 与 Checkstyle 均通过。较早的隔离 MariaDB 兼容性验证通过了 6 个 Action 测试和 Hosted 进程测试;最终 journal 校验修改在 H2 上验证。

测试平台

OS 状态
🍏 macOS ✅ 已测试
🪟 Windows ⚠️ 未测试
🐧 Linux ⚠️ 未测试

环境

Java 21、Node.js、MySQL 模式下的 H2,以及使用 MySQL JDBC 驱动的隔离 MariaDB 10.11.19 实例。测试后已清理 MariaDB 进程与临时数据目录。

风险与范围

  • 主要风险或取舍:回答投递依赖 journal 投影及 lease fencing;处于 recovery-blocked 的 Session,其回答保持 running,直到已提交证据证明结果。
  • 未验证或不在范围内:问题类 Action、投票、WebShell 审批 UI、待审批请求的重启恢复、Oracle MySQL,以及真实进程的 deny/auto-edit 变体。
  • 破坏性变化及迁移说明:Flyway V24 增加 Action 投影与回答 operation 元数据,已有 Session 默认为 yolo。V20–V22 已由工具发布功能使用,V23 用于 managed MCP records。default 与 auto-edit 要求 Harness 确认固定的审批模式。

英文设计与中文设计已同步。

关联 Issue

实现 #12867 的 D6b 切片。问题类 Action 与投票仍为 planned。

@wenshao

wenshao commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

E2E and precommit verification report (macOS, Java 21):

  • Final focused Java run: 40 passed, 0 failures/errors/skips. Includes 8 Action tests and 5 API contract tests, plus lifecycle/query/projector/configuration/connector regressions.
  • Packaged Hosted Harness + production Java connector + real Runtime worker: owner answers approvals through public API and WebShell, reader receives 403 action_forbidden, repeated responses return the same operation, decisions reconcile to decided, and file tool turns complete. Final process test: 1 passed, 3.957s.
  • Focused Action cases cover same-timestamp cursor pagination across surfaces, terminal detail, invalid/stale responses, creator-only answers, conflicting replay, transient delivery retry, lost answer after committed decision, competing decisions, recovery-blocked responses waiting for journal evidence, and malformed journal/decision rollback.
  • Earlier isolated MariaDB 10.11.19 with MySQL JDBC driver: 6 Action tests + 1 Hosted process test passed. The temporary database process and datadir were removed. This predates final strict journal/decision validation; those final changes were verified on H2. It is MariaDB compatibility evidence, not Oracle MySQL evidence.
  • npm run build, npm run typecheck, npm run bundle, and Java checkstyle:check: passed.
  • Four precommit audit rounds: open-ended, reverse, open-ended, reverse. Two initial suggestions were fixed; rounds 3 and 4 were clean with no Critical or new Suggestion. The user's after-round-5 Critical-only policy was retained; no fifth round was necessary.

Not established by this report: Windows/Linux, Oracle MySQL, or real-process deny/auto-edit variants. Question Actions, votes, approval UI and pending-approval restart recovery remain outside D6b.

@wenshao
wenshao marked this pull request as ready for review September 30, 2026 10:34
@wenshao

wenshao commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator Author

Fixed the CI failure in 373b93a. The asynchronous coordinator could call the shared Mockito mock between doAnswer().when(harness) and the intended resolveAction() registration, producing UnfinishedStubbingException. A deterministic forced interleaving reproduced the exact exception.

The test fixture now installs its default answer during bean creation and dispatches by unique Action ID through a concurrent map, without runtime stubbing or Mockito resets. Production code and behavioral assertions are unchanged.

Verification: full server suite 207/207 passed; focused Actions 8/8 passed; Checkstyle passed. Concurrent stress with 10,000 responses and 100,000 background calls passed. Audit rounds 5 and 6 found no Critical. The new SDK Java CI run completed successfully, including the previously failing Hosted process fault gates / MySQL 8.4 / Java 21 check.

@wenshao
wenshao dismissed a stale review via c11c1bd September 30, 2026 11:48
@wenshao

wenshao commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

Merged current main in c11c1bd and resolved the bilingual design conflict while preserving both changes. Moved the Actions migration to V23 because tool publication recovery now occupies V22.

Validation passed: build, typecheck, bundle, Checkstyle, all 239 managed-agent-server unit tests, and the real Hosted Public/WebShell owner approval E2E. Two additional open-ended/reverse audit rounds found no Critical defects.

@yiliang114

yiliang114 commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Two things from the WebShell side (#12867 D6):

1. Migration version collision after merging main. #12946 (H1) landed on main with V23__managed_mcp_records.sql, while this PR adds V23__managed_actions.sql. The file names differ, so git reports no conflict and CI here is green, but once both are on main Flyway finds two migrations with version 23 and the server fails to start. After merging main, this PR's migration needs to become V24__managed_actions.sql; its content does not change. For the same reason, the OpenAPI info.version is 1.24.0 on main (#12946) and still 1.23.0 here, so the new Actions routes should bump it to 1.25.0.

2. The WebShell approval UI is #13107 (draft). It only touches packages/web-shell/client (16 files) and shares no files with this PR. It consumes the generated types this PR adds: WebShellPermissionAction (with inputRevision, policyRevision, functionCallId, toolName, expiresAt), actions/query, actions/get, actions/respond with requestId, and the actions Session capability. It relies on the stable allow/deny option ids and on at most one pending approval per Turn, as the contract now states. Once this PR merges, I'll merge main into #13107 and mark it ready. If any of those names or fields change in review, please note it here.

中文说明

WebShell 侧(#12867 的 D6)有两点:

1. 合入 main 后迁移版本号会冲突。 #12946(H1)已经带着 V23__managed_mcp_records.sql 合入 main,而本 PR 新增的是 V23__managed_actions.sql。两个文件名不同,git 不报冲突,这里的 CI 也是绿的;但两者都进入 main 后,Flyway 会发现两个版本号为 23 的迁移,服务无法启动。合入 main 后,本 PR 的迁移需要改名为 V24__managed_actions.sql,内容不变。同理,OpenAPI 的 info.version 在 main 上已是 1.24.0(#12946),本 PR 仍是 1.23.0,新增的 Actions 路由应将其升到 1.25.0。

2. WebShell 审批界面在 #13107(draft)。 它只改动 packages/web-shell/client(16 个文件),与本 PR 没有重叠文件。它使用本 PR 新增的生成类型:WebShellPermissionAction(含 inputRevision、policyRevision、functionCallId、toolName、expiresAt)、带 requestId 的 actions/query、actions/get、actions/respond,以及 Session 的 actions 能力;并依赖契约中已写明的稳定 allow/deny 选项 id 和"每个 Turn 至多一个待审批请求"。本 PR 合入后,我会把 main 合入 #13107 并标记为 ready。如果上述名称或字段在评审中有变动,请在这里说明。

yiliang114 and others added 3 commits September 30, 2026 20:50
…e contract to 1.25

Merging main brought in #12946's V23__managed_mcp_records.sql next to this
PR's V23__managed_actions.sql; Flyway refuses two migrations with the same
version, so the server would not start. The Actions migration becomes V24
with its content unchanged. The contract takes 1.25.0 on top of main's
1.24.0 (#12946), with a v1.25 note for the Actions routes this PR serves.
yiliang114 pushed a commit that referenced this pull request Sep 30, 2026
Picks up #13101's renumbering of the Actions migration to V24. The
branch still carried V23__managed_actions.sql, which collides with
main's V23__managed_mcp_records.sql in CI's merge with main.

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the D6b path end to end at 358212a8 — admission → durable operation → worker → Harness resolve → journal projection → completion — plus the authorization surface, the V24 migration, and the generated client.

What I verified: regenerating managed-agent-api.ts from this PR's OpenAPI (npm run generate:managed-agent-api) produces a clean diff, so the generated types match the contract. CI is green on this head (Java 11/17/21, Hosted MySQL 8.4, the MariaDB job, Checkstyle, Lint & Static). I couldn't run the Java suite locally (no JDK/Maven on this machine), so everything below is a source read at the pinned SHA.

Both of your points from the 12:29 comment are addressed at the head: the Actions migration is now V24__managed_actions.sql and the contract is 1.25.0. The migration content itself looks right.

The main flow reads as designed, and the owner gate plus the replay/conflict handling in ActionResponseCoordinator hold up — I found no injection, replay-flip, or re-decision path. Two things I'd tighten before merge:

  1. ManagedActionsTest never actually runs against MariaDB — its datasource keys off d6b.mysql.*, which nothing in the repo sets, so the DB matrix claimed in the design doc isn't exercised by CI.
  2. QwenHostedHarnessConnector's 3-arg constructor silently disables the approval-mode pin and its confirmation guard.

Everything else is non-blocking and inline. One inline note is a question rather than a finding: the projection's duplicate-record guard, where I couldn't construct a reachable path but the sibling projection on the same call site behaves differently.


@SpringBootTest(
properties = {
"spring.datasource.url=${d6b.mysql.url:jdbc:h2:mem:managed-actions;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE}",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking, but the claim needs fixing. This datasource only leaves H2 if d6b.mysql.url is set, and nothing in the repo ever sets it. Every other MySQL IT in this module keys off mysql.url (HostedHarnessMySqlIT:94, ToolPublicationRecoveryMySqlIT:30), and the mysql-integration job passes only -Dmysql.url / -Dmysql.user / -Dmysql.password — so this test takes the H2 branch even in that job, and design §7's "projection and route tests on H2 and MariaDB (MySQL driver)" isn't reproduced by CI.

The V24 DDL does still run against real MySQL 8.4 through the Hosted ITs, so this is a vacuous verification claim rather than an unvalidated migration. The module convention (${mysql.url:jdbc:h2:...} + driver-class-name) would let the MariaDB leg actually be switched on, and asserting the database product would make a mis-wire fail loudly instead of silently falling back.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed at 358212a: the CI matrix passes mysql.* and this class reads d6b.mysql., so a green MariaDB job must not be counted as this Action suite running on MariaDB. The earlier six-test MariaDB result was a separate manually configured local run, as qualified in the PR report; the final strict-validation evidence is H2. Deferred: wire this suite into the shared mysql. matrix and assert the database product. Per the established after-round-5 policy, this non-blocking coverage improvement is a follow-up rather than a change to this PR.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not change this one, on purpose. Wiring it to the shared mysql.url would point this test at the same managed_agent_test database the rest of the mysql-integration job uses, and the H2 default is an isolated in-memory DB — two Spring contexts with different datasource properties against one schema is exactly the kind of interference I cannot check without a JVM. Dropping the forced org.h2.Driver default is also required before -Dmysql.url could work at all, since Boot only infers the driver when driver-class-name is absent. Leaving the call to you: either keep the opt-in property and soften the design §7 wording to say the MariaDB leg is manual, or wire it into a job with its own database.


public QwenHostedHarnessConnector(ManagedAgentProperties properties,
AgentStateStore sessions, WorkspaceExecutionStore workspaceExecution) {
this(properties, sessions, workspaceExecution, null);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 3-arg constructor now passes null for the new actions dependency, and every new use site tolerates that by degrading silently: create() then sends the deployment default mode instead of the mode pinned on the Session row (253-259), and the confirmation guard at 118 is skipped entirely. So a Session pinned default/auto-edit can be created with yolo and never verified against what the Harness echoed back — the exact case design §6.4 declares fail-closed. No error, no log.

Today only the 4-arg constructor is used in production wiring (HarnessConfiguration:26), so this is latent rather than live. Might be worth dropping the null-tolerant overload, or failing loudly for a Workspace-bound Session, so an un-wired store can't quietly disable the pin.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed the nullable overload is unsafe if used for a Workspace-bound Session. I checked all constructor call sites at 358212a: production HarnessConfiguration uses the four-argument constructor with ManagedActionStore; the three-argument overload is used only in tests. The live path reads the pinned mode and verifies the Harness echo, so I found no currently reachable production bypass. Deferred: remove the nullable overload or reject Workspace use without the store. This remains a non-blocking wiring improvement under the after-round-5 Critical-only policy.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7ab2952: createOrLoad now throws when a Workspace-bound Session has no Managed Action store, so neither the mode pin nor the Harness confirmation can be skipped. With that guard in place the two actions != null checks were dead, so they are gone. Both tests that use the 3-arg constructor build non-Workspace sessions (SessionRecord compact constructor → workspace = null), so they are unaffected.


private boolean hasActions(SessionRecord session) {
return session.workspace() != null
&& actions != null

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasActions issues its own SELECT approval_mode for every Session it renders, and it's called from both publicSession (503) and webShellSession (525) — so the Session list endpoints now add N queries for a column of the row the store already loaded. Worth carrying approval_mode in the session projection and deriving the capability from that.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: hasActions adds an approval_mode lookup per Workspace Session rendered. This is a non-blocking query-efficiency suggestion. Deferred: carry the pinned mode through the existing Session projection and derive the capability without the extra lookup. Recorded here for follow-up under the after-round-5 Critical-only policy.

previous == null
? "requested".equals(state)
: previous.options().equals(options)
&& "requested".equals(previous.state())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question rather than a finding — the projection isn't idempotent: a requested record re-applied for an action that already exists fails this guard, and since require (538) throws a 400 and apply runs before the journal_tx insert (ManagedSessionStore:348 vs :361), the whole commit is rejected — and per design §5.4 a failed journal write also blocks the Session's later writes.

I could not construct a reachable producer for that branch: a retry on the same command key short-circuits in replay() (ManagedSessionStore:315), a different command key carrying the same content fails validateHead first, and the harness mints a fresh tool_approval_<32hex> per request (hosted-workspace-tool-turn.ts:1378). So it may be a purely defensive assertion. But the sibling Stage H projection on the same call site does tolerate a re-applied record (ManagedExtensionRecordStore:98, the applied/shaped bookkeeping), so the asymmetry is worth a second look. Could you confirm whether a duplicate action.changed record can reach apply? If it can, this should be a silent no-op instead of a 400.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked the current commit end to end. An identical command retry finds its existing transaction and returns replay before actions.apply. A different command replaying the old sequence fails validateHead before projection. A new requested event reusing an existing Action ID is not a valid producer transition: Hosted creates a fresh ID per request, and the Action lifecycle is requested followed by one terminal transition. I found no reachable valid duplicate that should be accepted here. Keeping the guard prevents a malformed new transaction from resetting an existing Action; no change needed for this question.

id);
}
if (jdbc.queryForObject(
"SELECT COUNT(*) FROM managed_agent_session WHERE tenant_id = ? AND"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This COUNT(*) runs after the INSERT/UPDATE above and silently skips the action.updated event when the count isn't 1, so that path leaves a persisted Action row with no public event and no log. The caller has already validated the head row for this session (commitJournal → validateHead), so the query is redundant on the happy path and only guards a half-applied state on the other one. Dropping it (or failing loudly instead of skipping) would remove both.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed the conditional event append and the redundant query on the normal product Session path. I found no demonstrated live product path that loses an Action event: projection is inside the journal commit transaction, and normal Workspace Actions have the product Session row. Deferred: clarify the journal-only case and replace the conditional guard with an explicit invariant where appropriate. This is a non-blocking simplification under the after-round-5 Critical-only policy.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Withdrawing this one — I was wrong about the caller. appendPublicEventIfAbsent (ManagedAgentStore:1643) starts with requireSessionForUpdate, which throws when there is no managed_agent_session row, and that projection table is not the qwen_managed_session_journal_head row the caller validated. So without the COUNT(*) a journal commit for a Session that is not projected yet would fail the whole commit instead of skipping the event. The guard is load-bearing; no change made.

sessionId);
}

public void requireOwner(String tenantId, String sessionId, String actorId) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requireOwner calls actorKey without the IllegalArgumentException catch that the sibling call sites use to answer 403 actor_scope_mismatch (ManagedWorkspaceRegistry:36-39 and 141-145), so an actor scope that WorkspaceActor rejects would surface here as an unmapped 500-class error instead of the contract's 403. Fail-closed either way, so not a blocker — but the neighbouring routes are consistent about mapping it and this one isn't.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed the exception mapping differs from the neighbouring registry calls. It does not grant access or bypass the owner check, but the error response should be made consistent. Deferred: map an invalid actor scope to the declared 403 and cover that path. Recorded as a non-blocking follow-up under the after-round-5 Critical-only policy.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7ab2952: the actor key is resolved first, and an IllegalArgumentException now answers 403 actor_scope_mismatch, matching ManagedWorkspaceRegistry:141-145. An unknown or absent creator still answers action_forbidden.

List.of(
"inputRevision",
"policyRevision",
"createdAt",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createdAt/expiresAt are copied straight from the Harness optionsRef, i.e. epoch milliseconds, while the public Session representation divides by 1000 (ManagedAgentService:487-488) and the WebShell Session view does not (517). The public API therefore mixes seconds and milliseconds across resources: a client that follows the Session convention to compute a countdown from expires_at is off by 1000×, and expiry is exactly what a responder consults. Worth pinning the unit in the contract (or naming the fields ..._ms) and asserting it in the contract test.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: these Action fields expose the original Harness epoch milliseconds on both surfaces, while public Session timestamps use seconds. The unit is insufficiently explicit in the public contract. I found no current Action UI/countdown consumer in this slice. Deferred: explicitly pin the Action timestamp unit in the contract and assert it, considering compatibility before renaming or converting fields. Recorded under the after-round-5 Critical-only policy; this PR does not silently change existing units.

private ManagedActionService actions;

@Autowired
void setActions(ManagedActionService actions) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ACTION_RESPONSE operation envelope is built here through a setter-injected back-reference (117-131) while ManagedActionService also constructs its own PublicCommandOperation/WebShellCommandOperation (130-172). So the wire shape and the web ? "x_y" : "xy" naming rule now live in two places, and a SessionLifecycleService constructed without the setter NPEs on the ACTION_RESPONSE path instead of failing at wiring time. Keeping a single projection builder would remove both.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked the current code: SessionLifecycleService delegates ACTION_RESPONSE to ManagedActionService.publicOperation/webOperation; the Action wire envelope itself is built there, rather than duplicated in the lifecycle service. The remaining concern is valid: constructing the lifecycle service manually without its setter leaves that branch unwired. Production Spring wiring supplies the dependency. Deferred: simplify dependency wiring while retaining a single Action projection builder. No live production defect found; retained as a non-blocking follow-up under the after-round-5 policy.

.header(TENANT, tenant).principal(actor).header(IDEMPOTENCY_KEY, "contract-answer"), body);
exchange(drift, "respondWebShellAction", 202, post(WEB_SHELL + "/actions/respond").header(TENANT, tenant).principal(actor),
"{\"sessionId\":\"%s\",\"actionId\":\"%s\",\"requestId\":\"action-trace\",\"idempotencyKey\":\"contract-answer\",\"response\":{\"kind\":\"permission\",\"inputRevision\":1,\"policyRevision\":\"hosted-tool-approval/1\",\"optionId\":\"allow\"}}".formatted(session, journal.id));
journal.change("cancelled", null);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

action_cancelled is produced here but never asserted. endedCode maps cancelled → action_cancelled (ManagedActionStore:470-475), and the suite only checks action_expired (ManagedActionsTest:367, :417) and action_already_resolved (:309, :475). The D6a path cancels an Action when the Turn is aborted, so a response racing that cancellation is reachable; one assertion that it completes with failure_code/failureCode action_cancelled and returns no resolution would cover the third terminal state.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed the cancellation fixture does not assert the resulting failure envelope. The implementation maps cancelled to action_cancelled; the comment identifies missing regression coverage rather than a demonstrated incorrect result. Deferred: assert failure_code/failureCode=action_cancelled and no resolution on both surfaces. Recorded as a non-blocking test follow-up under the after-round-5 Critical-only policy.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 7ab2952 as cancelledActionsFailTheResponseWithActionCancelled, mirroring the expiry test: delivery keeps failing, the journal then records cancelled, and the operation must end FAILED with failure_code action_cancelled and no action_resolution.

`action.updated` Session event. The table
needs a Flyway migration; open pull requests hold V19 to V21, so its number is
settled at merge.
uses Flyway migration V23; tool publication uses V20–V22.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still says the Actions table "uses Flyway migration V23" (same at ...zh-CN.md:121), and the PR body says "Flyway V23 adds the Action projection" — but after the renumber the migration is V24__managed_actions.sql, because V23 is held by #12946's managed_mcp_records. Small drift, but this is the section a future reader uses to reason about the migration chain.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: the current migration is V24__managed_actions.sql, with V23 occupied by managed_mcp_records. I corrected the PR description to V24. The stale V23 sentence in both linked design documents is recorded here as a deferred documentation correction under the after-round-5 Critical-only policy; the migration chain itself is already correct.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7ab2952 in both the EN and zh-CN design docs: V24 for the Actions table, with V23 attributed to the Session MCP catalog (#12946). I left the PR body alone — it still says V23 in the two "Breaking changes / migration notes" paragraphs.

doudouOUC
doudouOUC previously approved these changes Sep 30, 2026

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 358212a8b2ac661bdfa960533e4de395fb05e7a4 across all 32 changed files and the downstream Hosted authority, Workspace admission, journal store, operation readers and delivery workers. No blocking correctness or security defects found after the initial review and independent reverse passes.

Verified the creator-only response gate and tenant/read isolation, strict original-revision/option validation, actor-scoped cross-surface idempotency, transactional Action/event projection, deterministic decision reconciliation after lost replies, competing responses, lease-generation fencing, and approval-mode pinning with fail-closed Harness confirmation. V24 and OpenAPI 1.25.0 address the earlier migration/version collision.

Fresh validation at this commit:

  • 92 Java tests passed, with zero failures, errors or skipped tests: 19 Hosted SDK tests, 17 focused Action/contract/config/connector tests, 55 related lifecycle/Workspace/server regression tests, and one real Hosted integration test. The SDK and Runtime Broker artifacts were rebuilt from this commit.
  • The real Hosted Harness and Runtime worker completed four owner approvals across public and WebShell APIs, rejected a reader with 403, replayed the original operation, reconciled committed decisions, performed the expected file operations, and avoided duplicate physical execution.
  • npm run build, npm run bundle and npm run typecheck passed; the tracked checkout remained clean.

I also rechecked the newly posted review threads. The nullable connector overload is currently used only by tests; production wiring supplies the Action store and enforces the pin. Same-command journal retries return through the replay path before Action projection; I found no reachable valid duplicate-request transition that needs to be accepted. The existing test-coverage, wiring, query and documentation suggestions remain non-blocking and are not duplicated here.

Local database evidence is H2 with a deterministic local model, not fresh MySQL/MariaDB verification. In particular, the focused Action test's d6b.mysql.* properties are not driven by the current MariaDB CI job; its green result must not be counted as that test running on MariaDB. Pending-approval restart recovery, questions/votes and the WebShell approval UI remain the documented follow-up scope. CI has no failed checks at this review; WebShell smoke and automated review were still running.

@wenshao

wenshao commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@wenshao
wenshao disabled auto-merge September 30, 2026 13:52
A Workspace Session whose connector was built without the Managed Action
store silently fell back to the deployment approval mode and skipped the
Harness mode confirmation -- the case the design declares fail-closed.
createOrLoad now refuses it, so the two remaining null checks in the
connector are gone and an un-wired store fails loudly at admission.

requireOwner let an IllegalArgumentException from actorKey escape as an
unmapped 500. It now answers 403 actor_scope_mismatch, matching the
neighbouring workspace routes.

endedCode maps cancelled to action_cancelled but no test asserted it: a
cancelled Action now fails its response operation with that code and
returns no resolution.

The design docs still named the Actions migration V23 after it moved to
V24; V23 belongs to the Session MCP catalog (#12946).
@yiliang114

Copy link
Copy Markdown
Collaborator

Pushed 7ab29525 to this branch with four of the review follow-ups:

  • QwenHostedHarnessConnector — a Workspace-bound Session with no ManagedActionStore now fails at createOrLoad instead of silently using the deployment approval mode and skipping the Harness mode confirmation. With that guard in place the two actions != null checks were dead, so they are gone. Both tests using the 3-arg constructor build non-Workspace sessions, so they are unaffected.
  • ManagedActionStore.requireOwner — an invalid actor scope now answers 403 actor_scope_mismatch instead of escaping as an unmapped 500.
  • ManagedActionsTest — added cancelledActionsFailTheResponseWithActionCancelled, covering the third terminal state that endedCode already maps.
  • Design docs (EN + zh-CN) — the Actions migration is V24; V23 belongs to the Session MCP catalog (feat(managed-agent): Implement private Hosted MCP runtime (H1) #12946).

I also withdrew one of my own findings inline: the COUNT(*) guard in the projection is load-bearing, because appendPublicEventIfAbsent starts with requireSessionForUpdate and the projection table is not the journal head table the caller validated.

Verification caveat: there is no JDK or Maven on the machine I reviewed from, so none of this was compiled or run locally. The changes are small and mirror existing patterns, but the Java matrix is the only thing that can confirm them — worth watching before merge.

Left untouched, with reasons in the inline threads: the d6b.mysql.* datasource (wiring it to the shared mysql.url risks cross-context interference I cannot check without a JVM), the per-Session approval_mode query, the setter-injected operation envelope, and the projection's duplicate-record guard (still a question — I could not construct a reachable path to it).

@wenshao

wenshao commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@yiliang114

Copy link
Copy Markdown
Collaborator

Follow-up to my review, after reading your ten replies — three corrections and one piece of context.

Context on 7ab29525. I read your deferral notes after I had already pushed, so let me be explicit about why four items you deferred under the after-round-5 Critical-only policy landed anyway: a maintainer asked for them to be handled directly on this branch rather than carried to a follow-up. None of them change behavior on the live path — the connector guard only fires for a wiring that production does not produce, requireOwner maps an error that previously escaped as a 500, the cancelled test is additive, and the doc edit is text. Evidence from the run on that commit: Runtime Broker and Managed Agent MariaDB / Java 21 → BUILD SUCCESS, 0 Checkstyle violations, ManagedActionsTest 9 tests / 0 failures (was 8); Hosted process fault gates / MySQL 8.4 / Java 21 and Real daemon E2E / Java 11 both green. If you would rather keep this PR at round-5 scope, say so and I will revert the commit — it is one self-contained commit.

Corrections to my own review:

  1. I told you the PR body still said V23. It does not — you had already corrected both paragraphs to V24 before I posted that. My mistake.
  2. I withdrew the COUNT(*) finding inline already, and your framing of it is the better one: the guard is load-bearing because appendPublicEventIfAbsent starts with requireSessionForUpdate, and the projection table is not the journal head table the caller validated. What is left is your point about making the journal-only case an explicit invariant rather than a silent conditional.
  3. On SessionLifecycleService I overstated it as a duplicated envelope. You are right that the Action wire shape is built once, in ManagedActionService; only the unwired-setter concern stands, and production Spring wiring supplies the dependency.

Closed by your analysis: the projection's duplicate-record guard. Your producer walk matches what I could not construct either — same command key returns at replay, a different key fails validateHead, and Hosted mints a fresh Action ID per request. Keeping the reject is the right call; no change needed.

Still open as follow-ups, per your notes: the d6b.mysql.* datasource wiring plus a database-product assertion, carrying the pinned mode through the Session projection to drop the per-row approval_mode lookup, pinning the Action timestamp unit in the contract, and simplifying the lifecycle-service wiring. I deliberately did not touch the timestamp unit here — converting it would land on the expiresAt consumer in #13107.

@wenshao

wenshao commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

Real-environment verification at 7ab2952507

Verdict: ready to merge. Every claim in the description reproduces on a real stack, the unit and Hosted suites pass locally and in CI, and nothing I found is a regression. Two limits are worth knowing before default or auto-edit is switched on for real users (F1 and F2 below). Both are inherited from main or belong to follow-up work, and the default mode stays yolo.

The head moved twice while I was running (c11c1bdf3c → 358212a8b2 → 7ab2952507). I re-ran the whole sequence on each head. The numbers below are from 7ab2952507 unless marked.

What ran

  • the server fat jar built from the head (JDK 21.0.12) with its embedded Runtime Broker, plus a trusted-actor adapter on loader.path
  • the packaged Hosted Harness (dist/cli.js serve --profile hosted-harness, Node 24.18.1) and real Runtime workers, bundled from the same tree
  • MySQL 8.4.7 (native, UTC) on macOS arm64
  • a scripted OpenAI-compatible model for deterministic tool calls, and one run with a real model (qwen3.8-max)
  • a recording proxy between the server and the Harness that can drop, delay or replace a call
  • four actors: alice creates the Session, bob can only read, carol is another creator of the same Workspace, mallory has no access

Results

Area Result
Reviewer Test Plan, Session created through the public API 41/41
Reviewer Test Plan, Session created through WebShell 41/41
Deny, mixed batch, allow-vs-deny races, 20 identical requests, request validation 44/44
Expiry with an 8 s timeout: nobody answers, three calls in a row, answer delivered late 12/12
auto-edit and yolo file Turns 8/8 and 8/8
Delivery faults: lost reply, four failed deliveries, 503 twice, 400 15/15
action.updated on the public and WebShell event streams 5/5
Database created by main 3a8fd11711, upgraded V23 → V24, then the test plan 8/8 and 41/41
Startup validation, 13 mode and timeout combinations as documented
Real model: allow through the public API, deny through WebShell 4/4
1,626 recorded live responses against the shipped OpenAPI 1.25.0 document all valid
Unit suite on JDK 21 251/251
HostedPublicWorkspaceIT on local MySQL 8.4.7 2/2

The description lists deny, auto-edit, Oracle MySQL and restart recovery as not validated. The first three are covered above with real processes (MySQL 8.4.7 here, 8.4.6 in CI). Restarts are F1.

What the test plan asks for, as measured:

  • Both surfaces. The pending approval has the same id on the public API and WebShell, and the Session reports capabilities.actions: true on both. Nothing runs while it waits (file absent, 0 tool executions, Turn running).
  • Who may answer. bob and carol get 403 action_forbidden on both surfaces. mallory, a request without an actor and another tenant get 404 session_not_found. None of them creates an operation.
  • Idempotency. The same key returns the same operation on the same surface and on the other one. The same key with deny instead of allow answers 409 idempotency_conflict. 20 concurrent identical requests produce one operation.
  • Completion. The operation completes with action_resolution.outcome: decided and a decision receipt, the Turn finishes, and the file holds the edited content after 3 tool executions and 4 model calls.
  • Terminal state. The decided Action stays readable with its receipt and leaves the pending list. A new answer gets 409 action_already_resolved. The original key still returns the original operation.
  • Lost reply. The proxy let the Harness record the decision and dropped its 200. The operation completed decided from the committed journal record, with one call to the Harness and no retry.
  • Temporary failures. Four dropped deliveries were retried after about 2, 2, 4 and 8 s. The Session stayed ACTIVE and the Turn RUNNING throughout, then the operation completed.

Beyond the test plan:

  • Deny. The write never runs, the model gets "The Session owner denied this tool call" and the Turn completes. With the real model the deny produced one approval and no retry.
  • Mixed batch. Two calls in one assistant message are asked one at a time. Allowing the first and denying the second runs exactly one.
  • Races. Allow and deny sent together under different keys: one is recorded, the other operation ends failed with action_already_resolved, and the file system matches the winner in 3 of 3 runs.
  • Expiry. The Turn ends by itself 9.9 s after creation with an 8 s timeout. With three sequential writes only the first is asked about and the Turn ends after one timeout. An answer admitted in time but delivered 10 s late ends failed with action_expired, and the call does not run.
  • Migration. At c11c1bdf3c I hit the V23 collision independently: git merged cleanly and the merged server refused to start with Found more than one migration with version 23. The rename in 57b9960ca2 fixes it. A fresh database goes 22 → 23 → 24, and a database created by main upgrades in place with its Sessions left at yolo.
  • 7ab2952507. This commit was pushed from a machine without a JDK, so I built it. It compiles, the unit suite is 251/251 including the new cancelledActionsFailTheResponseWithActionCancelled, the Hosted IT is 2/2 and the whole real-stack sequence passes on it. The first ubuntu-latest / Java 11 run was red because of the runner: maven-javadoc-plugin … zip END header not found in "Build release artifacts". That step passes locally on JDK 11.0.32, and the re-run is green.

F1: a Java server restart strands a pending approval

This is the "restart recovery of pending approvals" that the description lists as not validated. I restarted only the Java server and left the Harness process running.

After the restart the approval is still listed on both surfaces and the owner's allow is accepted with 202. It is never delivered. The connector has to re-attach with POST /session/:id/load, and the Harness that still holds the Session answers 409 hosted_session_already_attached every time. The resolve route is never reached. A Session created after the restart on another Workspace is asked, answered and completed by the same Harness.

During the outage What happens Runs
The Harness writes nothing to the Store (outages of 4 to 19 s) The Harness expires the Action at its expiry time. The operation ends failed with action_expired at its next retry, 5 to 26 s later. 5
A Store write from the Harness fails (outages of 23 to 55 s) The Session becomes recovery-blocked. The Action stays requested and listed, and the operation stays running. 5

Both outcomes fail closed: the allowed call never ran in any run. In both, the Java Turn stays running and new Sessions on that Workspace fail with hosted_turn_failed.

With the default 10 minute timeout, the answer was accepted 4 s after the approval appeared and the operation ended failed with action_expired 608 s later, after 15 attempts.

This is inherited. On main 3a8fd11711 in yolo, a Turn that is waiting for the model when the server restarts ends the same way: load answers 409, the Java Turn stays RUNNING and the Workspace stays busy. What D6b changes is the exposure. A Turn now waits for a person for up to the approval timeout, so a deploy is far more likely to land inside one.

Not a blocker. Suggestions:

F2: the owner cannot see what the call will do

The Action carries the tool name and the function call id. No client-visible surface shows the arguments, before or after the answer. I checked the Action detail on both surfaces, the Session Items, the events, the WebShell transcript and the Turn detail. While the Turn waits, Items hold one entry, the user message.

The design says the assistant message is committed first "so a client can show the pending calls from the Session's Items" (5.2) and "Arguments come from Items" (6.3). The message is in the journal, but the Java projection does not expose tool calls. So today an owner approves write_file without knowing the path or the content.

Not a blocker for the API in this PR. It matters before default is offered to people, and for #13107, whose card takes the arguments from the tool row. Either project the pending call into Items, or correct the two design sentences until that lands.

Smaller notes

  • Harness that does not report approvalMode. With a Harness bundle from before D6a, or a proxy that strips the field, every Workspace Session fails after about 35 s with hosted_harness_unavailable. Nothing runs, which is the intended fail-closed behaviour for default. It also happens in yolo, where the old Harness would behave correctly, so the Harness has to be upgraded before or with the Java server. An optional 6-line candidate accepts a missing mode only for a Session pinned to yolo. With it a yolo Session completes on the old Harness in 1.8 s, default still refuses, the test plan is 41/41 and the unit suite is 251/251.
  • Workspace held while waiting. A second Session on the same Workspace is accepted with 202 and its Turn fails 1 to 2 s later with hosted_turn_failed. The Workspace serves new Sessions again once the first Turn ends. This is the documented trade-off, with a number on it.
  • No other way out of a waiting Turn. WebShell turns/cancel, a second message, close and delete all answer 409 workspace_unavailable for a bound Session. The owner's answer or the expiry ends the wait, so action_cancelled cannot be produced through Java today.
  • Timestamps. Live data confirm the open thread: Action created_at and expires_at are milliseconds, the created_at of the same Session's public events is seconds.

Tests

39 single-edit mutants of the PR's Java code:

Layer Killed
Unit suite 21
HostedPublicWorkspaceIT, on the survivors 3 more
8 candidate unit tests 10 more
Left 5, two of them equivalent

Survivors of the unit suite that the candidate tests cover:

  • the action.updated event is appended
  • a question response is refused
  • an ended Action cannot change again, and a known Action's options cannot change
  • an expired worker lease is reclaimed
  • a Harness 400 ends the operation
  • an exactly full last page reports no more
  • the lifecycle worker never sees Action responses

The last one guards real behaviour. The lifecycle worker closes the Session of every operation it claims, and only the operation_kind <> 'ACTION_RESPONSE' filter keeps it away. No test in the PR fails when that filter is removed.

The candidate class passes Checkstyle and runs 8/8 at 7ab2952507. I offer it as a follow-up, not a condition for merging.

Not covered

Evidence

Probe scripts, transcripts for all three heads, the mutation runs and the candidates are in pr13101/ at 2f14a1eaa7.

1. Reviewer Test Plan, 41/41 on each surface

test plan

2. Deny, mixed batch, races, validation

decisions

3. Expiry, auto-edit, yolo, startup validation

expiry and modes

4. Delivery faults, event streams, busy Workspace

delivery faults

5. F1: Java server restart with a pending approval

restart

6. Migration collision at the earlier head, upgrade from main at this head

migration and upgrade

7. Suites, contract conformance, mutation testing

suites, contract, mutation

8. F2: visibility of the call; Harness without approvalMode

visibility and skew

9. Real model round trip

real model

中文版本

真实环境验证(head 7ab2952507)

结论:可以合并。 PR 描述里的每条主张都在真实栈上复现,单元测试与 Hosted 测试在本地和 CI 均通过,没有发现回归。在对真实用户开启 default 或 auto-edit 之前,有两个限制值得知道(下文 F1、F2)。两者要么继承自 main,要么属于后续工作,而默认模式仍是 yolo。

验证期间 head 动了两次(c11c1bdf3c → 358212a8b2 → 7ab2952507),每个 head 我都把整套流程重跑了一遍。下面的数字除特别标注外都来自 7ab2952507。

跑了什么

  • 由该 head 构建的服务端 fat jar(JDK 21.0.12),内嵌 Runtime Broker,loader.path 上挂一个可信 actor 适配器
  • 打包后的 Hosted Harness(dist/cli.js serve --profile hosted-harness,Node 24.18.1)和真实 Runtime worker,来自同一棵源码树
  • MySQL 8.4.7(原生、UTC),macOS arm64
  • 脚本化的 OpenAI 兼容模型,用于产生确定的工具调用;另有一轮使用真实模型(qwen3.8-max)
  • 服务端与 Harness 之间的记录代理,可丢弃、延迟或替换某次调用
  • 四个 actor:alice 创建 Session,bob 只读,carol 是同一 Workspace 的另一个创建者,mallory 无权限

结果

范围 结果
Reviewer Test Plan,经公共 API 创建 Session 41/41
Reviewer Test Plan,经 WebShell 创建 Session 41/41
拒绝、混合批次、allow 与 deny 竞争、20 个相同请求、请求校验 44/44
8 秒超时下的过期:无人回答、连续三次调用、回答迟到 12/12
auto-edit 与 yolo 的文件 Turn 8/8、8/8
投递故障:应答丢失、四次投递失败、两次 503、一次 400 15/15
公共与 WebShell 事件流上的 action.updated 5/5
由 main 3a8fd11711 建库,升级 V23 → V24,再跑测试计划 8/8、41/41
启动校验,13 种模式与超时组合 与文档一致
真实模型:公共 API 回答 allow,WebShell 回答 deny 4/4
1,626 条真实响应对照随包发布的 OpenAPI 1.25.0 文档 全部合规
单元测试,JDK 21 251/251
HostedPublicWorkspaceIT,本地 MySQL 8.4.7 2/2

PR 描述把 deny、auto-edit、Oracle MySQL 和重启恢复列为未验证。前三项已在上表用真实进程覆盖(本地 MySQL 8.4.7,CI 为 8.4.6)。重启见 F1。

测试计划要求的各项,实测如下:

  • 两个入口。 待审批请求在公共 API 与 WebShell 上 id 相同,Session 在两边都报告 capabilities.actions: true。等待期间没有任何执行(文件不存在,工具执行 0 次,Turn 为 running)。
  • 谁能回答。 bob 和 carol 在两个入口都得到 403 action_forbidden。mallory、不带 actor 的请求、其他租户得到 404 session_not_found。这些请求都没有产生 operation。
  • 幂等。 同一个键在同一入口、换一个入口都返回同一个 operation。同一个键把 allow 换成 deny 得到 409 idempotency_conflict。20 个并发相同请求只产生一个 operation。
  • 完成。 operation 以 action_resolution.outcome: decided 和决定回执完成,Turn 结束,文件是编辑后的内容,共 3 次工具执行、4 次模型调用。
  • 终态。 已决定的 Action 仍可读取并带回执,同时从待审批列表消失。新的回答得到 409 action_already_resolved。原来的键仍返回原 operation。
  • 应答丢失。 代理让 Harness 记录了决定,再丢掉它的 200。operation 依据已提交的 journal 记录以 decided 完成,只调用了 Harness 一次,没有重试。
  • 临时失败。 四次被丢弃的投递分别在约 2、2、4、8 秒后重试。期间 Session 一直是 ACTIVE,Turn 一直是 RUNNING,之后 operation 完成。

测试计划之外:

  • 拒绝。 写入没有执行,模型收到"The Session owner denied this tool call",Turn 完成。真实模型被拒绝后只产生一次审批,没有重试。
  • 混合批次。 同一条 assistant 消息里的两个调用逐个询问。允许第一个、拒绝第二个,只执行了一个。
  • 竞争。 用不同的键同时发 allow 和 deny:只记录一个,另一个 operation 以 failed、action_already_resolved 结束,文件系统状态与胜者一致,3 次都如此。
  • 过期。 8 秒超时下,Turn 在创建后 9.9 秒自行结束。连续三次写入只询问第一次,Turn 在一个超时后结束。及时受理但迟到 10 秒才送达的回答以 failed、action_expired 结束,调用不执行。
  • 迁移。 在 c11c1bdf3c 上我独立撞到了 V23 冲突:git 合并无冲突,合并后的服务端拒绝启动,报 Found more than one migration with version 23。57b9960ca2 的改名修复了它。新库按 22 → 23 → 24 迁移,由 main 建的库可原地升级,已有 Session 保持 yolo。
  • 7ab2952507。 这个提交是从一台没有 JDK 的机器推送的,所以我构建了它。它能编译,单元测试 251/251(包含新增的 cancelledActionsFailTheResponseWithActionCancelled),Hosted IT 2/2,整套真实栈流程在它上面通过。ubuntu-latest / Java 11 第一次变红是 runner 的问题:"Build release artifacts" 步骤里 maven-javadoc-plugin … zip END header not found。同一步骤在本地 JDK 11.0.32 上通过,重跑已变绿。

F1:重启 Java 服务端会搁浅待审批请求

这就是 PR 描述里列为未验证的"待审批请求的重启恢复"。我只重启 Java 服务端,Harness 进程保持运行。

重启后,审批仍在两个入口的列表里,owner 的 allow 也被 202 受理,但永远送不到。connector 必须用 POST /session/:id/load 重新挂接,而仍持有该 Session 的 Harness 每次都回答 409 hosted_session_already_attached,resolve 路由从未被调用。重启后在另一个 Workspace 上新建的 Session,由同一个 Harness 正常询问、回答并完成。

停机期间 结果 次数
Harness 没有向 Store 写入(停机 4 到 19 秒) Harness 在过期时间把 Action 置为过期。operation 在下一次重试时以 failed、action_expired 结束,晚 5 到 26 秒。 5
Harness 的一次 Store 写入失败(停机 23 到 55 秒) Session 进入 recovery-blocked。Action 保持 requested 并留在列表里,operation 保持 running。 5

两种结果都是失败关闭:所有运行里,被允许的调用都没有执行。两种情况下 Java 侧的 Turn 都停在 running,该 Workspace 上的新 Session 以 hosted_turn_failed 失败。

在默认的 10 分钟超时下,回答在审批出现 4 秒后被受理,operation 在 608 秒后以 failed、action_expired 结束,共尝试 15 次。

这是继承来的问题。在 main 3a8fd11711 的 yolo 模式下,服务端重启时正在等模型的 Turn 结局相同:load 回答 409,Java 侧 Turn 停在 RUNNING,Workspace 一直被占用。D6b 改变的是暴露面:Turn 现在会等人,最长等到审批超时,一次发布落在 Turn 中间的概率大了很多。

不阻塞合并。建议:

F2:owner 看不到这次调用要做什么

Action 带有工具名和 function call id。没有任何客户端可见的入口展示参数,回答前后都是如此。我检查了两个入口的 Action 详情、Session Items、事件、WebShell transcript 和 Turn 详情。Turn 等待期间 Items 只有一条,即用户消息。

设计文档说先提交 assistant 消息,"这样客户端可以从 Session 的 Items 展示待处理的调用"(5.2),以及"参数来自 Items"(6.3)。消息确实在 journal 里,但 Java 的投影没有把工具调用暴露出来。所以现在 owner 批准 write_file 时,并不知道路径和内容。

对本 PR 的 API 不构成阻塞。在把 default 提供给用户之前需要解决,也影响 #13107,它的卡片从工具行取参数。要么把待处理的调用投影进 Items,要么在那之前先修正设计文档里的这两句话。

其他说明

  • 不回报 approvalMode 的 Harness。 换成 D6a 之前构建的 Harness,或用代理去掉该字段,所有 Workspace Session 都在约 35 秒后以 hosted_harness_unavailable 失败。没有任何执行,这对 default 是预期的失败关闭。但 yolo 下也会如此,而旧 Harness 在 yolo 下的行为本来是正确的,所以 Harness 必须先于 Java 服务端升级,或与它一起升级。附了一个可选的 6 行候选:只对固定为 yolo 的 Session 接受缺失的模式。打上之后,yolo Session 在旧 Harness 上 1.8 秒完成,default 仍然拒绝,测试计划 41/41,单元测试 251/251。
  • 等待期间 Workspace 被占用。 同一 Workspace 上的第二个 Session 被 202 受理,1 到 2 秒后它的 Turn 以 hosted_turn_failed 失败。第一个 Turn 结束后,该 Workspace 又能服务新 Session。这是文档里已说明的取舍,这里给出了实测数字。
  • 等待中的 Turn 没有别的出口。 对绑定 Workspace 的 Session,WebShell turns/cancel、第二条消息、close、delete 都回答 409 workspace_unavailable。只有 owner 的回答或过期能结束等待,所以目前经 Java 产生不了 action_cancelled。
  • 时间戳。 实测数据印证了那条未关闭的评审意见:Action 的 created_at、expires_at 是毫秒,同一 Session 公共事件的 created_at 是秒。

测试

对 PR 的 Java 代码做了 39 个单点变异:

层 杀死
单元测试 21
HostedPublicWorkspaceIT(针对存活者) 再 3 个
8 个候选单元测试 再 10 个
剩余 5 个,其中 2 个等价

候选测试覆盖的单元测试存活变异:

  • 追加了 action.updated 事件
  • question 类回答被拒绝
  • 已结束的 Action 不能再变,已知 Action 的选项不能变
  • 过期的 worker 租约会被回收
  • Harness 返回 400 会结束 operation
  • 恰好满页的最后一页报告没有更多
  • 生命周期 worker 永远看不到 Action 回答

最后一条守护的是真实行为。生命周期 worker 会关闭它认领的每个 operation 所属的 Session,只有 operation_kind <> 'ACTION_RESPONSE' 这个过滤条件把它挡在外面。去掉这个过滤后,PR 里没有任何测试失败。

候选测试类通过 Checkstyle,在 7ab2952507 上 8/8 通过。我把它作为后续项提供,不作为合并条件。

未覆盖

证据

探针脚本、三个 head 的运行记录、变异测试记录和候选补丁都在 2f14a1eaa7 的 pr13101/ 目录下。截图见上方英文部分。

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 7ab2952. No provable blocking correctness, security, data-loss, regression, or compatibility defect. What I checked myself at this head, rather than inheriting from the existing threads:

The two items previously graded "tighten before merge" are resolved or non-blocking in the current code. QwenHostedHarnessConnector now fails closed instead of degrading: a Workspace-bound Session constructed without a ManagedActionStore throws IllegalStateException at lines 108-109, and the pinned-mode confirmation at lines 121-123 throws when the Harness echoes a different approval mode, so the "pinned default/auto-edit created as yolo and never verified" path no longer exists. Production wiring is unaffected either way — HarnessConfiguration:26 builds the connector with the four-argument constructor that supplies the store, and createOrLoad selects the pinned mode through actions.approvalMode(...) at lines 256-260. ManagedActionStore.requireOwner (lines 58-65) now answers an invalid actor scope with actor_scope_mismatch rather than letting it escape as an unmapped 500, and it is called on the response path at line 336 before any mutation, with every read keyed by tenantId plus sessionId.

The earlier migration/version collision is genuinely closed. There is exactly one V24__managed_actions.sql in db/migration at this head, so the renumber off #12946's V23__managed_mcp_records leaves no duplicate Flyway version — the failure mode that would have broken store startup is not present.

Remaining unresolved threads are non-blocking by their own grading and are agreed follow-ups. The Action test datasource keying off d6b.mysql.* weakens a coverage claim rather than a gate: the V24 DDL is still exercised against real MySQL 8.4 through the Hosted integration tests, and the MariaDB and MySQL fault-gate lanes are green on this head, so no unvalidated migration ships. The per-Session approval_mode lookup is query efficiency, the lifecycle-service setter wiring is a construction-time concern that Spring wiring satisfies, and the projection's duplicate-record guard was closed as correct by both sides. The Action timestamp unit is the only contract-shaped item, and it affects a resource this PR introduces rather than changing any existing field's meaning, so it is not a compatibility regression; leaving it aligned with the expires_at consumer landing separately is a reasonable sequencing choice. None of these is a defect I can prove at this head, and per the review scope I am not restating them as suggestions.

CI is green on this head across the Java 11/17/21, MariaDB, MySQL 8.4 fault-gate, real-daemon E2E, Lint & Static, and WebShell lanes; only review-pr is still pending, which is not treated as a gate.

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at 7ab2952507. CI is green across the Java 11/17/21, MariaDB, MySQL 8.4 fault-gate, real-daemon E2E, Lint and WebShell lanes; the Flyway collision is closed (single V24__managed_actions.sql after #12946's V23), and the real-stack verification above covers both surfaces, permissions, idempotency, expiry, delivery faults and the V23 → V24 upgrade. The open threads are non-blocking follow-ups. F1 (a Java server restart strands a pending approval) and F2 (arguments not visible to the approver) must be closed before default or auto-edit is offered to users; they are tracked with Stage G (#12952) and #12867.

@yiliang114
yiliang114 added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 78143fe Sep 30, 2026
269 of 276 checks passed
yiliang114 added a commit that referenced this pull request Sep 30, 2026
Main's permission Actions work (#13101) and this branch's Workspace-bound
later Turns both extended WebShellSessionCapabilities with a second field,
so the record, its single construction site, the generated WebShell types
and the cross-tenant contract assertion each need the union rather than one
side.

- ApiModels: the record is now (tasks, actions, workspaceTurns).
- ManagedAgentService: pass both hasActions(session) and
  maySubmitWorkspaceTurn(session, actorId).
- managed-agent-api.ts: emit actions then workspaceTurns, matching the
  contract's property order now that neither field is planned; actions stays
  required and workspaceTurns optional per the schema's required list.
- ManagedAgentApiContractTest: the foreign-tenant session asserts both
  capabilities as false.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmuob3c85i1
yiliang114 added a commit that referenced this pull request Sep 30, 2026
The deferral resolved: #13101 merged and took 1.25.0, and #13117 took
1.26.0. Record this PR's contract change per the version-history
convention: creator later-Turn admission and the workspaceTurns
capability.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuoob5v90j
neoneye pushed a commit to agent-memory-atlas-archive/QwenLM--qwen-code that referenced this pull request Oct 1, 2026
… panel (QwenLM#13107)

* feat(managed-agent): Serve durable permission Actions (D6b)

* test(managed-agent): Avoid concurrent Action mock stubbing

* feat(web-shell): show and answer Hosted tool approvals in the Managed panel

The Managed panel passed pendingApproval={null}, so a Hosted Session
waiting on a D6a approval showed nothing to answer. Build on the D6b
Actions contract (QwenLM#13101):

- The provider gains an optional Actions reader. The Java provider lists
  requested permission Actions through actions/query and answers through
  actions/respond with the Action's inputRevision and policyRevision and
  a per-Action, per-option idempotency key. The Session summary reports
  the actions capability only when the service does.
- action.updated projects to action_updated. It carries no Turn, so the
  transcript skips it instead of settling the Turn being streamed.
- useManagedActions re-reads pending approvals on action_updated, on a
  stream gap, after an expiry and after an answer. It hides an answered
  approval and brings it back if the answer fails.
- The page maps the pending Action onto the shared approval card, with
  allow/deny as allow_once/reject_once so labels are localized, and
  attaches it to the tool row `${turnId}:${functionCallId}` so the row
  expands and shows its arguments.

Not built, type-checked, linted or tested locally.

* feat(web-shell): export the pending Action type for custom Managed providers

* fix(managed-agent): renumber the Actions migration to V24 and bump the contract to 1.25

Merging main brought in QwenLM#12946's V23__managed_mcp_records.sql next to this
PR's V23__managed_actions.sql; Flyway refuses two migrations with the same
version, so the server would not start. The Actions migration becomes V24
with its content unchanged. The contract takes 1.25.0 on top of main's
1.24.0 (QwenLM#12946), with a v1.25 note for the Actions routes this PR serves.

* fix(web-shell): memoize pending actions to keep the respond callback stable

The conditional selected a fresh empty array on every render, so the
useCallback dependency changed identity each time and ESLint's
react-hooks/exhaustive-deps warning failed the lint gate.

* style(web-shell): format the Managed approvals page test with Prettier

* fix(web-shell): retry failed approval reads and name them as such

A transient failure of actions/query on the first read left a pending
approval invisible until a manual Refresh: summary polls do not re-read
Actions, and without an Action there is no expiry timer. The page then
showed "The approval answer could not be sent" although nothing was
sent.

- useManagedActions reports loadError and answerError separately, and
  retries a failed read after 2, 5 and 10 seconds before giving up.
  retry() reads again on demand and restarts the bound.
- The page shows "Pending approvals could not be loaded." with a Retry
  button for a read failure, and keeps the answer message for a failed
  answer.
- Tests cover the automatic recovery, the retry bound and the manual
  retry.

* fix(web-shell): recover failed approval answers and bound expiry reads

* fix(web-shell): Show available managed approval arguments

* fix(web-shell): keep the approval card when an answer was not applied

* fix(web-shell): keep Managed approvals stable across reloads and ended Actions

Treat an unknown session summary as unknown rather than as no Actions
capability, so a reload keeps the shown approval answerable. Drop an
approval the service reports as ended instead of offering a retry, scope
answer failures to the approval that failed, stop retrying client errors,
restart the retry ladder whenever reads resume, and carry the
arguments-unavailable notice inside the approval card.

* fix(web-shell): land only the Critical R1-1 fix for Managed approvals

The previous commit also carried the review's Suggestions. The PR is past
its review-round budget, so only R1-1 stays here: an unknown session
summary keeps the shown approval instead of reading as no Actions
capability. The Suggestions move to the follow-up recorded in QwenLM#12867.

* fix(web-shell): match Managed approvals to itemId-keyed tool rows

Since QwenLM#13037, Managed tool rows are keyed ${turnId}:${itemId} and Java tool
Items always carry an itemId, so findManagedApprovalTool no longer found the
row for a pending Action: the card could not show that row's arguments or
expand it. Keep the producer's call ID on the row and match on it, still
within the Action's Turn.

Candidate patch from wenshao's round-4 real-stack verification (R4-1).

* test(web-shell): pin the page half of the Managed approval reload fix

Reverting the page gate back to detail.summary?.capabilities.actions === true
kept every test green (G9). Hold a reload's Session summary and assert the
shown approval stays and no extra Actions read happens.

Candidate test from wenshao's round-3 real-stack verification.

* test(web-shell): spell canCancel in the Managed approvals fixtures

The three approvals fixtures handed to `summary()` omitted
`capabilities.canCancel`, which is a required member, so each one was a
TS2741 that no gate in this package reports (both tsconfigs exclude
`client/**/*.test.tsx` and vitest does not typecheck). They also modelled a
summary the real mapper cannot emit, which always sets both flags.

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

* fix(web-shell): keep the Harness call title on Managed approval cards

`toManagedPermissionRequest` reads `tool.title` for the approval
`title`, but no producer ever set it: `managedEventsToMessages` only
assigned args/status/times/rawOutput, so the left branch was dead and the
card description degenerated into a restatement of the heading while the
Harness`s own per-call title was dropped.

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

* fix(web-shell): stop Managed approval state outliving its Action

Two lifecycle leaks in `useManagedActions`:

- a successful re-read cleared `loadError` but never `answerError`, so
  "the answer could not be confirmed" stayed on screen after the Harness
  ended the Action, and then labelled the next, unrelated card. The
  warning now carries the Action it belongs to and is dropped once that
  Action is no longer pending.
- a withdrawn reader cleared `pending` without resetting the retry budget,
  so a reader restored after the ladder was exhausted got a single attempt
  with no retry scheduled, contradicting the hook`s own comment.

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

* fix(web-shell): stop retrying approval reads the service already answered

R1-5: a 4xx other than 408/429 is the service's answer, not a hiccup, so a
deleted Session no longer costs four guaranteed-failing /actions/query
requests and a Retry that can never succeed. The classification the submit
path already used moves to managed-request-error.ts and is reused, so both
failure paths of the feature agree.

R1-4: the hook reports whether the pending approvals were read for this
Session, so a failed background re-read is named as a refresh instead of
claiming nothing could be loaded next to the loaded, actionable card.

R1-8: the "arguments are unavailable" caveat renders as a sibling of the
approval dialog, so ToolApproval takes an extra description id and the panel
describes the caveat too — otherwise a screen-reader user confirms a Hosted
tool call hearing only the tool name.

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

* test(web-shell): pin four Managed approval paths that no test observed

R1-10, four sites the review mutation-tested as unpinned:

1. the `stream_gap` arm of the re-read trigger — the durable transcript is
   the only route that reports a gap here, since use-managed-session breaks
   the live loop on a gap before merging it; the hook doc now says so.
2. the session-switch reset effect and the cross-session mask — the new test
   switches Session while the previous card is displayed and its failed
   answer is still flagged, so deleting the effect leaves the warning behind
   and reverting the mask leaves the stale card answerable.
3. the `pendingApproval` wiring into MessageList — the page test's mock now
   captures the prop, which is what keeps the turn owning the pending call
   from being folded away.
4. the `setAnswerError(undefined)` clear on a successful answer — the retry
   test now ends on the alert being gone, not just on the card.

Every assertion was checked to fail with the production line it covers
reverted (8/8 mutations RED).

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

* fix(web-shell): isolate late Managed approval replies

Ignore answer settlements once their Session or pending Action has changed,
and clear or display warnings only for the Action they belong to.

Pin late success and failure after Session switches and Action replacement.

* fix(web-shell): drop a Managed approval the service reports as ended

Answering an Action that already expired, was cancelled or was answered
elsewhere returns 409 with a code the contract defines as ended, and no
retry can succeed. Keep the card hidden, clear the warning and read the
list again instead of offering a retry. A read that still lists the Action
shows it again, so a wrong report cannot hide an approvable Action. The
403 actor_scope_mismatch is not an ended Action and keeps its handling.

---------

Co-authored-by: Shaojin Wen <[email protected]>
Co-authored-by: yiliang114 <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: yiliang114 <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants