* 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]>
13 KiB
Managed Agent Session Query (Stage D2)
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:
PublicSessionlackedagent_revisionandcapabilities, which the schema requires, and thereplay_floor_sequenceandsnapshot_through_sequencewatermarks.PublicTurnlackedinput_item_id.- Error envelopes had no
request_id, and no response carriedX-Request-Id. The WebShellrequestIdwas accepted and never read. WebShellStreamRequeststill carried thelimitthat the contract removed.- The contract names input blocks
input_text; the server accepted onlytext, 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
workspacelacked the requiredcontext_revisionandstate, 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,webShellGetSessionandwebShellCreateSessiontoimplemented. - 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_expiredand resync. These are D3. - Durable archive and delete operations (lifecycle work) and context changes
(W2), which raise
context_revisionand report the otherstatevalues. - 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
archivedafter an archive, andarchivingordeletingwhile that mutation waits for a retry after the Harness was unavailable. A Turn readscancellingbetween a cancellation's admission and its settlement. The plannedarchived_atfield is unchanged. If the lifecycle work later represents archiving asclosedplusarchived_at, that is a contract change of its own. ErrorEnvelope.error.request_idbecomes required, as section 3 of the public API contract that the D1 note links already states.- Two shared responses are added:
Unauthorized(401) andUnavailable(503). The implemented operations declare every status the server's own handlers return for them. Session create on both surfaces adds401 actor_requiredfor a Workspace selection without a trusted actor, and503when the Hosted Harness is unavailable. Session get adds400 invalid_tenant, and the WebShell session query and get add400. Session get and list on both surfaces add403 actor_scope_mismatchfrom the tenant filter. Framework responses such as405and415stay undeclared, and a client that accepts no JSON gets a406without a body. The partial operations gain the400and404responses the new probes exercise:400on the event query and the Items list, and400and404on the WebShell transcript, event stream, submit and cancel. - The six Session routes above move to
implemented.implementedcovers an operation's surface that is notplanned, so it includes thepartialworkspacefield, 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
capabilitiesreportsitems: trueandsnapshots,resyncandartifactsasfalse. 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 tofalse.replay_floor_sequenceis0. Events are never pruned yet; D3 persists the floor.snapshot_through_sequenceis the covered sequence of the Session's Snapshot, the same value the Items list returns, or0before 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 oflast_event_id. A stream reconciliation discards the Snapshot, so the watermark can also drop back to0until the Items are rebuilt.input_item_idisitem_<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_textand 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_idandarchiving. It reads a bound Session on both surfaces and checkscontext_revision1andstateready. - Every response must carry
X-Request-Id, an error'srequest_idmust equal it, and a WebShellrequestIdmust be echoed. A separate test sends a safe and an unsafeX-Request-Idand an unsafe bodyrequestId. - A request or response gap line may not name an
implementedoperation. - 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_textand requires a server with this change. - A WebShell
requestIdlonger than 128 characters is now rejected with400, as the contract's schema requires. - The generated
@qwen-code/web-shelltypes now requirerequest_idin the error envelope, and the client's create and submit requests takeinput_textblocks. The client's Session type requirescontextRevisionandstatein 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
requestIdecho, droppingrequest_idfrom the envelope, returning a different status on one surface, listing a gap for animplementedoperation, and rejectinginput_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
1from 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
406test 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
closedplusarchived_at. - W2: context changes raise
context_revision, reportchangingandrecovery_blocked, and move theWorkspaceContextschemas out ofpartial.