Files
qwen-code/docs/design/2026-09-27-managed-agent-session-query.md
T
f5c9bf8a6a feat(managed-agent): store immutable AgentDefinition revisions (Stage D8a) (#13142)
* feat(managed-agent): store immutable AgentDefinition revisions (Stage D8a)

Implement POST /v1/agents, GET /v1/agents/{agentId} and
POST /v1/agents/{agentId} as tenant-scoped, append-only revisions. The
server generates agent IDs; each revision carries the SHA-256 of its
canonical content, an update with unchanged content adds no revision, and
create and update replay or conflict under their Idempotency-Key. Flyway V27
adds the revision and command tables.

The tenant filter now also covers the bare /v1/agents path, which the
/v1/agents/ prefix check let through. Sessions still pin qwen-code and the
configured revision (D8b), and no definition field changes execution (D8c).
The contract moves the three routes to implemented at v1.29.0, assuming
#13112 lands v1.28 first.

* fix(managed-agent): replay concurrent identical AgentDefinition writes

The local verification on #13142 found that eight identical concurrent
creates answered one 202 and seven 409s: a duplicate command key surfaced
as idempotency_conflict although the same key and body must replay. A
write that loses a race now rolls back and reads the command committed
under its key, replaying it for the same request; a different request or
an unrecorded concurrent update keeps its 409.

Array items must be objects, so null items in tools, skills and
mcp_servers answer 400 instead of being stored, and the contract declares
skills, mcp_servers and metadata nullable, matching their treatment of
null as absent.

* fix(managed-agent): declare the replay header on AgentDefinition writes

createAgent and updateAgent answer 202 with X-Qwen-Idempotent-Replay, as
createSession, updateSession and unarchiveSession do, but the contract did
not declare it on the new routes. Declaring it also makes the contract test
require the header on both responses.

* docs(managed-agent): scope three design claims to what the code does

Corrects the D8a design doc (and its zh-CN twin) where it described
behavior the implementation does not have:

- §4.1 called the stored `definition_json` column the canonical JSON. It is
  written by the injected Spring ObjectMapper, while the row's digest comes
  from RequestDigests' alphabetically-sorted mapper, so sha256 of the column
  does not equal the digest. §4.2 keeps "canonical" for the digested form.
- §4.2 stated a universal "any other path ID answers 404 agent_not_found".
  Literal routes outrank `{agentId}`, so `/v1/agents/sessions` and
  `/v1/agents/workspaces` are answered by their own controllers. The
  operative conclusion (reserved names never resolve to a stored row) holds.
- §4.2 stated null-normalization as a general digest property. `content()`
  strips nulls one level only, so a null nested in an open map such as
  `metadata` does change the digest.

Docs only: no code, spec, or test changes. Verified by reading the sources
each sentence describes.

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

* fix(managed-agent): bump AgentDefinition migration to V28

Merging main brought in V27__managed_hook_records.sql (#13129), which
collides with this branch's V27__managed_agent_definitions.sql. Flyway
rejects two migrations sharing a version ("Found more than one migration
with version 27"), and every test that migrates the whole
classpath:db/migration directory (WorkspaceStorageGuardTest,
RuntimeBrokerFlywaySchemaTest, HostedWorkspaceConcurrencyIT) would fail
at startup, as would the server itself.

main's V27 is already landed, so this branch takes the next free slot.

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

* docs(managed-agent): stop claiming the no-op update publishes a revision

updateAgent answers 202 both when it appends a revision and when the
submitted digest already equals the latest one, in which case nothing is
stored and X-Qwen-Idempotent-Replay is still false. The 202 description
asserted "New immutable revision published", contradicting the operation
description directly above it, so a client trusting the response-level
text would record a publication that never happened.

Description prose only. OpenApiContract reads
x-qwen-implementation-status, schema property names and validates
response instances; it never reads "description", so the contract lane
cannot observe this change.

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

* fix(managed-agent): normalize tenant-filtered request paths

* fix(managed-agent): lock the head revision read in AgentDefinition update

update() decided "content equals the latest revision, so store nothing"
from a non-locking latest() read. The command lookup before it opens the
transaction's snapshot, so a revision committed in between is invisible:
a write whose content matched the stale head inserted nothing, neither
DuplicateKeyException branch could fire, and the route answered 202 with
a revision that was no longer the head. The command row then pinned that
Idempotency-Key to the stale revision, so every retry replayed it and the
write could never land.

Take the head read FOR UPDATE so the digest comparison and the revision
allocation both see the committed head, and concurrent updates of one
definition serialize. get() keeps the non-locking latest(), so reads do
not contend with writers. The SQL shape is the module's own:
ManagedToolResultStore.java:116 issues the same ORDER BY ... LIMIT 1 FOR
UPDATE, exercised on H2 by ToolPublicationStoreTest.backfillOnePage.

The published updateAgent description and the design doc's concurrency
bullet both described the revision-number collision this replaces, so
both now state the serialized behavior. The 409 branch stays as a
fallback; idempotency_conflict is unchanged.

* docs(managed-agent): drop the stale single-revision claim from D2

D8a stores definition revisions, so the D2 session-query non-goal "Only
one agent revision exists until it lands" is now false, and the new D8a
page links D2 as a basis. Reword both language versions the way this
branch already reworded the D1 contract twin: definitions are stored,
Sessions keep pinning the configured revision until D8b.

* fix(managed-agent): move the AgentDefinition migration to V33

main now has V31 (workspace recovery bundle) and V32 (workspace session
close), so this branch's V31 collided and failed the Flyway uniqueness lane.
Take the next free version.

* Revert "fix(managed-agent): lock the head revision read in AgentDefinition update"

This reverts commit 7d7cc0e414.

@wenshao's real-stack run on MySQL 8.4 (REPEATABLE-READ) showed that
latestForUpdate ... FOR UPDATE deadlocks under contention: 274 of 320
contested updates and 264 of 320 identical-request updates answered 500. He
also reproduced the R2-1 interleaving on the plain read and found its outcome
byte-identical to the legal serial order (T2, then T1), so there is no lost
write to fix. Restore the plain head read and the docs and contract text that
describe it.

* test(managed-agent): pin two AgentDefinition gaps and declare getAgent's 400

From @wenshao's real-stack mutation check: a filter that skips percent
decoding (/v1/%61gents) and a request digest without the agent ID both
passed the suite. Pin each with a test. getAgent answers 400 invalid_tenant
through the tenant filter, so declare it as the other two routes do.

* docs(managed-agent): clarify concurrent definition update semantics

---------

Co-authored-by: yiliang114 <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
2026-10-02 15:59:53 +00:00

13 KiB

Managed Agent Session Query (Stage D2)

English | 简体中文

Status: implemented in this change Date: 2026-09-27 Issue: #12793, part of #12380 Builds on: Managed Agent API Contract (Stage D1)

1. Problem

D1 put the reviewed OpenAPI in this repository and recorded every difference between it and the server as an expected failure. The Session create, list and get routes on both surfaces still had the following gaps:

  • PublicSession lacked agent_revision and capabilities, which the schema requires, and the replay_floor_sequence and snapshot_through_sequence watermarks. PublicTurn lacked input_item_id.
  • Error envelopes had no request_id, and no response carried X-Request-Id. The WebShell requestId was accepted and never read.
  • WebShellStreamRequest still carried the limit that the contract removed.
  • The contract names input blocks input_text; the server accepted only text, which the WebShell client sent.
  • An archived Session read back with status: "archived", which the contract's status enum lacked.
  • Several operations did not declare error statuses that the server returns, so the contract test never validated those envelopes.
  • W0d (#12797) binds empty Sessions to a Workspace. A bound Session's workspace lacked the required context_revision and state, which W0d recorded as gaps on the create and get routes.

The issue's exit check for D2 is that these six routes move to implemented, that one Session reports the same identity, status and sequence on both surfaces, and that cross-tenant reads return 404 session_not_found.

2. Goals

  • Close every D2 gap recorded in D1 without adding new gap lines.
  • Move createSession, listSessions, getSession, webShellListSessions, webShellGetSession and webShellCreateSession to implemented.
  • Give every response a trace-only request id, and put it in every error envelope and in the logs.
  • Prove parity between the two surfaces for one Session.

3. Non-goals

  • Event versions, top-level Item and Part identity, real has_more, event limits up to 1000, a persisted replay floor, cursor_expired and resync. These are D3.
  • Durable archive and delete operations (lifecycle work) and context changes (W2), which raise context_revision and report the other state values.
  • AgentDefinition. D8a stores definition revisions; Sessions keep pinning the configured revision until D8b pins a stored one.

4. Decisions

4.1 Contract v1.15

  • The Session and Turn status enums gain the values the server already returns. A Session reads archived after an archive, and archiving or deleting while that mutation waits for a retry after the Harness was unavailable. A Turn reads cancelling between a cancellation's admission and its settlement. The planned archived_at field is unchanged. If the lifecycle work later represents archiving as closed plus archived_at, that is a contract change of its own.
  • ErrorEnvelope.error.request_id becomes required, as section 3 of the public API contract that the D1 note links already states.
  • Two shared responses are added: Unauthorized (401) and Unavailable (503). The implemented operations declare every status the server's own handlers return for them. Session create on both surfaces adds 401 actor_required for a Workspace selection without a trusted actor, and 503 when the Hosted Harness is unavailable. Session get adds 400 invalid_tenant, and the WebShell session query and get add 400. Session get and list on both surfaces add 403 actor_scope_mismatch from the tenant filter. Framework responses such as 405 and 415 stay undeclared, and a client that accepts no JSON gets a 406 without a body. The partial operations gain the 400 and 404 responses the new probes exercise: 400 on the event query and the Items list, and 400 and 404 on the WebShell transcript, event stream, submit and cancel.
  • The six Session routes above move to implemented. implemented covers an operation's surface that is not planned, so it includes the partial workspace field, which section 4.4 completes.

4.2 agent_revision

The revision comes from the new setting qwen.managed-agent.agent-revision (environment variable QWEN_MANAGED_AGENT_REVISION, default 1). It is written to the Session row at admission, so a later change of the setting does not rewrite existing Sessions. Flyway V13 adds the column and gives existing rows 1.

A create request may name agent_revision. A new admission that names a value other than the current revision is rejected with 400 unsupported_feature. The store checks this after the idempotency replay lookup and before it reserves the creation or resolves a Workspace, so a retry of an admitted creation still returns the original Session after the setting changes. An explicit revision is part of the idempotency digest and an omitted one is not, so digests of requests that omit it do not change. A retry that adds, drops or changes the field is a different request and gets 409 idempotency_conflict. The same holds for a request that named the field before this change, whose digest ignored it; no client in this repository sends the field.

4.3 Capabilities and watermarks

  • capabilities reports items: true and snapshots, resync and artifacts as false. Snapshot reset and resync arrive with D3, and the contract forbids declaring them before the server can honour them. The planned optional flags are omitted; the schema defaults them to false.
  • replay_floor_sequence is 0. Events are never pruned yet; D3 persists the floor.
  • snapshot_through_sequence is the covered sequence of the Session's Snapshot, the same value the Items list returns, or 0 before the first materialization. It is read by primary key without loading the Snapshot's Items, one lookup per Session, as the active Turn already needs. The Session row and the Snapshot are read separately, so while events are being materialized the watermark can briefly be ahead of last_event_id. A stream reconciliation discards the Snapshot, so the watermark can also drop back to 0 until the Items are rebuilt.
  • input_item_id is item_<turnId>_input, the id the input Item is materialized with.

4.4 Session workspace

A bound Session's workspace now carries context_revision and state on both surfaces. context_revision is the revision stored with the binding since V7, which is 1 at creation. state is always ready: nothing can change a context before W2, which adds changing and recovery_blocked. The WorkspaceContext schemas stay partial until then. The WebShell client's local type, which left the two fields out, now uses the generated one.

4.5 Request id

A filter that runs before tenant resolution assigns every request an id. It uses the incoming X-Request-Id when that is visible ASCII of at most 128 characters, and a random UUID otherwise. On WebShell create, submit and cancel, a requestId in the body replaces it once the handler runs. The contract allows any string of up to 128 characters there, so a value that is unsafe to echo in a header is ignored rather than rejected; a longer value fails validation with 400. A request that fails before the handler runs, such as one without a tenant or with an invalid body, keeps the id the filter assigned. The id is returned in X-Request-Id on every response, written to error.request_id, and placed in the log MDC, which the logging.pattern.correlation setting prints. The WebShell client now sends a fresh requestId with each create, including the empty bound create, and with each submit and cancel, instead of reusing the idempotency key, which the contract's RequestId header forbids. A retry therefore keeps its idempotency key and gets a new requestId.

4.6 Input type

The server accepts input_text and keeps accepting text from older clients. Both normalize to the same Harness input, so request digests and idempotent replays do not change. The WebShell client now sends input_text, which means a new client needs a server that includes this change.

4.7 JSON errors on SSE routes

The new error probes showed that a client sending only Accept: text/event-stream, which is what the WebShell client does, hit an unhandled exception instead of the 404 or 400 envelope, because the JSON envelope was not an acceptable representation; a servlet container turns that into a 500. Error responses now preset Content-Type: application/json, so content negotiation no longer drops them. Once a stream has started, the response is committed and an error can no longer become an envelope, so the handler leaves such a response alone rather than appending JSON to the stream. A request whose Accept header excludes JSON gets 406 without a body, as it did before the preset.

5. Contract test

  • The scenario sends input_text and names the current and a foreign agent revision. It probes every status that this change declares, including a request without a tenant, an authenticated actor from another tenant, a Workspace selection without an actor and a disabled Harness. Request bodies are validated against the schema only for calls that expect success, because error probes send invalid bodies on purpose.
  • The scenario reads a Session while its Turn is cancelling and while an archive waits for a retry, and checks cancelling, input_item_id and archiving. It reads a bound Session on both surfaces and checks context_revision 1 and state ready.
  • Every response must carry X-Request-Id, an error's request_id must equal it, and a WebShell requestId must be echoed. A separate test sends a safe and an unsafe X-Request-Id and an unsafe body requestId.
  • A request or response gap line may not name an implemented operation.
  • A parity test creates one Session through the WebShell adapter and compares the public get and list with the WebShell get and query: identity, agent, status and last sequence must match. It also pins the revision, the capabilities, the replay floor and the snapshot watermark against the Items list, and checks that cross-tenant reads on both surfaces return 404 session_not_found.
  • The gap file shrinks from 57 lines (the 51 of D1 plus the 6 that W0d added) to 17; what remains is D3 and lifecycle work.

6. Compatibility

  • Public Session and Turn responses, the Session workspace and error envelopes gain fields; nothing is removed.
  • Status values the server already returned (archived, archiving, deleting, cancelling) are now part of the contract.
  • The server accepts both input spellings. The WebShell client sends input_text and requires a server with this change.
  • A WebShell requestId longer than 128 characters is now rejected with 400, as the contract's schema requires.
  • The generated @qwen-code/web-shell types now require request_id in the error envelope, and the client's create and submit requests take input_text blocks. The client's Session type requires contextRevision and state in a Session workspace.
  • Flyway V13 adds a column with a default; no data is rewritten.

7. Validation

  • The Managed Agent server's full test suite and Checkstyle pass, including the contract and parity tests.
  • Mutations each fail the matching check: dropping the WebShell requestId echo, dropping request_id from the envelope, returning a different status on one surface, listing a gap for an implemented operation, and rejecting input_text.
  • A retry that names the admitted revision replays the original Session after the setting changes; skipping the replay lookup makes that test fail.
  • Before the JSON content-type fix, the SSE error probe failed with the exception the WebShell client would have seen as a 500.
  • A unit test checks that an error on an already committed stream writes nothing.
  • The MySQL upgrade test, which CI runs against MariaDB, reads revision 1 from a Session created before V13; the same upgrade was reproduced locally on H2 in MySQL mode.
  • The revision tests cover both creation paths and read the stored revision back through a service configured with another one. A 406 test covers unacceptable media types.
  • The WebShell typecheck, the managed component tests and the managed-progress e2e spec pass against the regenerated types. The W0d browser spec passes against WorkspaceBrowserFixtureMain, including a retry after a dropped create response.

8. Follow-up

  • D3: the event-replay gaps that remain in the gap file.
  • Lifecycle work: durable archive and delete, and whether archiving becomes closed plus archived_at.
  • W2: context changes raise context_revision, report changing and recovery_blocked, and move the WorkspaceContext schemas out of partial.