fix(managed-agent): round-2 review — migration version, stop-load pin, store guard, doc truths

R2-3 (fails-closed): the new migration took V58, below versions main
already shipped (V60/V62/V64), so with Flyway's default outOfOrder=false
it resolves-but-never-applies on any upgraded database and the
agent_definition_digest column every Session INSERT and read touches
silently does not exist. Renamed to V66, the next free version.

R2-1: messageStopAttachment is the third LoadHarnessSession site and the
only one that never carried the pinned agentDefinition, so a
definition-pinned child Session's message stop was refused with 409
hosted_agent_definition_conflict. The load now carries the pin, and
aMessageStopOfAPinnedSessionCarriesTheAgentDefinition pins it red/green.

R2-2: Session admission pinned a stored definition even when the Managed
Session Store the Harness create needs was disabled — a supported-looking
config (harness on, store off, files off) answered 202 for a Session that
could never run. validateWorkspaceFiles now refuses that shape at startup,
and ManagedAgentPropertiesTest witnesses it.

R1-28: two of the four falsified statements were still standing — the
README's revision-source sentence (a pinned Session records the revision
resolved at admission, not QWEN_MANAGED_AGENT_REVISION) and the
AgentDefinitionRequest javadoc's 'no field changes Session execution yet'.

Also from the same round: the 64 KiB agent-instructions bound is now one
named constant (MAX_AGENT_INSTRUCTIONS_BYTES) instead of four bare
literals across publisher, both verifiers and admission (R2-5); the
createAgent description's stale (v1.34) label is now v1.41 and names the
operator allowlists that gate model and tool profile (R2-13), the
webShellCreateSession description no longer promises a fixed server-owned
profile for definition-pinned Sessions (R2-6), and the workspaceTurns
predicate wording now matches the implementation (active and undeleted,
both branches on the frozen execution profile) (R2-11); the generated
managed-agent-api.ts was regenerated.
This commit is contained in:
yiliang114 2026-10-11 07:32:32 +08:00
parent 919e30be4a
commit 842d8a62e8
15 changed files with 86 additions and 15 deletions

View file

@ -246,7 +246,7 @@ The original plan splits delivery into two slices. Both slices are now implement
1. **PR A, D8b and D8c-1 (Java only):** pinning, compilation of the approval
mode and tool profile, the refusal rules, the new column (the next free
Flyway version on `main` at merge time) and contract 1.34. Definitions then
Flyway version on `main` at merge time) and contract 1.41. Definitions then
have their first real effect: selecting the approval mode, and `files/2`
on deployments that have opted in.
2. **PR B, D8c-2 (Java and Hosted Harness):** delivery, pinning,

View file

@ -207,7 +207,7 @@ instructionsRef?}`。
1. **PR A,D8b 与 D8c-1(只改 Java):** 固定版本、编译审批模式与工具 profile、
拒绝规则、新增列(取合入时 `main` 上下一个空闲的 Flyway 版本号)以及契约
1.34。之后定义就有了第一批实际效果:选择审批模式,以及在已开启的部署上
1.41。之后定义就有了第一批实际效果:选择审批模式,以及在已开启的部署上
选择 `files/2`。
2. **PR B,D8c-2(Java 与 Hosted Harness):** 模型与 instructions 的下发、
固定、核验和应用。

View file

@ -37,8 +37,10 @@ The test-tree `SurfaceRegistry` names every mounted route, internal ones
included, with its admission rule class; `SurfaceRegistryGateTest` fails any
mounted route the registry lacks, so a new public or WebShell route needs both
a spec operation and a registry entry ([actor-roles design](../../../docs/design/2026-10-07-managed-agent-actor-roles.md), D5).
Sessions record the agent revision from `QWEN_MANAGED_AGENT_REVISION` (default
`1`) when they are created. `POST /v1/agents`, `GET /v1/agents/{id}` and
Sessions created for the built-in `qwen-code` agent record the agent revision
from `QWEN_MANAGED_AGENT_REVISION` (default `1`); a Session pinned to a stored
definition records the revision it resolved at admission. `POST /v1/agents`,
`GET /v1/agents/{id}` and
`POST /v1/agents/{id}` store tenant-scoped, immutable AgentDefinition
revisions; a Session pinned to one executes that stored revision (see "Stored
agent execution" below). Every response carries `X-Request-Id`, which error

View file

@ -603,7 +603,8 @@ public final class ApiModels {
/**
* An AgentDefinition revision's content (D8a). The server stores and
* digests it; no field changes Session execution yet.
* digests it; a Session pinned to a revision executes its
* permission_policy, tools, model and instructions (D8b/D8c).
*/
public record AgentDefinitionRequest(
@NotNull Map<String, Object> model,

View file

@ -127,6 +127,13 @@ public class ManagedAgentProperties {
+ " a supported Harness, Session Store and Session-isolated"
+ " local-process Broker with Workspace mounts");
}
// A definition Session publishes its instructions through the
// Session Store at Harness create, so the Harness implies the
// Store even when Workspace files are off (D8c-2).
if (harness.isEnabled() && !sessionStore.isEnabled()) {
throw new IllegalStateException(
"Hosted Harness requires the Managed Session Store");
}
if (automation.isEnabled()) {
// A non-positive value fails at tick time, nearly silently:
// 0 slots per tick stops every scheduled fire behind one

View file

@ -598,7 +598,9 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
new LoadHarnessSession(session.sessionId(),
managedSessionStore(session),
session.harnessBootId() != null, toolProfile(session),
false, false).withChildWorkspaces(childWorkspaces)
false, false)
.withAgentDefinition(agentDefinition(session, false))
.withChildWorkspaces(childWorkspaces)
.withStoppedMessages());
if (session.workspace() != null && !actions.approvalMode(tenantId,
sessionId).equals(attached.getApprovalMode())) {

View file

@ -57,7 +57,8 @@ final class AgentDefinitionCompiler {
}
Object instructions = definition.get("instructions");
if (instructions != null && (!(instructions instanceof String text)
|| text.getBytes(StandardCharsets.UTF_8).length > 64 * 1024)) {
|| text.getBytes(StandardCharsets.UTF_8).length
> ManagedSessionStoreModels.MAX_AGENT_INSTRUCTIONS_BYTES)) {
throw unsupported("instructions");
}
String approvalMode = deploymentApprovalMode;

View file

@ -781,7 +781,7 @@ public class ManagedSessionStore {
String workspaceId, String sessionId, String instructions) {
validateScope(tenantId, workspaceId, sessionId);
byte[] bytes = instructions.getBytes(StandardCharsets.UTF_8);
if (bytes.length > 64 * 1024) {
if (bytes.length > ManagedSessionStoreModels.MAX_AGENT_INSTRUCTIONS_BYTES) {
throw payloadTooLarge("Agent instructions exceed 64 KiB.");
}
String digest = sha256(bytes);
@ -1437,7 +1437,8 @@ public class ManagedSessionStore {
|| "PUBLISHED".equals(resource.state()) && resource.schemaVersion() == 1
&& (resource.byteLength() > 0 && resource.byteLength() <= toolResultLimit(resource.kind())
|| "managed-agent-instructions".equals(resource.kind())
&& resource.byteLength() >= 0 && resource.byteLength() <= 64 * 1024))
&& resource.byteLength() >= 0
&& resource.byteLength() <= ManagedSessionStoreModels.MAX_AGENT_INSTRUCTIONS_BYTES))
|| resource.bytes() == null
|| resource.objectKey() != null
|| resource.objectVersionId() != null

View file

@ -14,6 +14,9 @@ public final class ManagedSessionStoreModels {
public static final String WRITER_TOKEN_HEADER =
"X-Qwen-Managed-Writer-Token";
public static final int MAX_INLINE_RESOURCE_BYTES = 64 * 1024;
/** The agent-instructions bound the publisher and both verifiers
* must agree on (admission, publish, verify, recovery read). */
public static final int MAX_AGENT_INSTRUCTIONS_BYTES = 64 * 1024;
public static final int MAX_RESOURCES_PER_TRANSACTION = 1024;
public static final int MAX_TRANSACTION_BYTES = 8 * 1024 * 1024;
public static final int MAX_TRANSACTION_EVENTS = 256;

View file

@ -74,7 +74,7 @@ final class WorkspaceRecoveryReader {
// commits; it stays PUBLISHED for its whole life.
|| "managed-agent-instructions".equals(text(ref, "kind"))
&& number(row, "byte_length") >= 0
&& number(row, "byte_length") <= 64 * 1024);
&& number(row, "byte_length") <= ManagedSessionStoreModels.MAX_AGENT_INSTRUCTIONS_BYTES);
check(("REFERENCED".equals(row.get("state")) || published) && row.get("object_key") == null
&& row.get("object_version_id") == null && row.get("encryption_key_id") == null,
"resource_layout_unsupported");

View file

@ -37,7 +37,7 @@
"post": {
"tags": ["Public Resources"],
"operationId": "createAgent",
"description": "Stores revision 1 of a new tenant-scoped definition under a server-generated agent ID. The same Idempotency-Key with the same body replays the original revision; a different body answers 409 idempotency_conflict. Sessions may be created from a stored definition by agent_id (v1.34): an explicit agent_revision pins that revision, an omitted one resolves the latest at admission, and permission_policy, tools, model and instructions then take effect for that Session; content that cannot take effect yet is refused at admission with 409 agent_definition_unsupported.",
"description": "Stores revision 1 of a new tenant-scoped definition under a server-generated agent ID. The same Idempotency-Key with the same body replays the original revision; a different body answers 409 idempotency_conflict. Sessions may be created from a stored definition by agent_id (v1.41): an explicit agent_revision pins that revision, an omitted one resolves the latest at admission, and permission_policy, tools, model and instructions then take effect for that Session (model must be on the deployment's agent-model allowlist, the tool profile on its tool-profile allowlist); content that cannot take effect yet is refused at admission with 409 agent_definition_unsupported.",
"x-qwen-implementation-status": "implemented",
"parameters": [
{
@ -1209,7 +1209,7 @@
"tags": ["WebShell"],
"operationId": "webShellCreateSession",
"x-qwen-implementation-status": "implemented",
"description": "Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. Any caller holding the OPERATOR role may submit later Turns and rename the Session under the same opt-in, while the Workspace registry row stays ACTIVE and still backs the binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is active and undeleted and is either a qwen-code Session on the frozen execution profile or pinned to a stored definition; Workspace close follows its separate close capability and lifecycle admission; the same caller may cancel a running Turn (its delivery re-checks no grants); archive and unarchive follow their separate retention capabilities after reliable Workspace close; delete supports either L3 idle ACTIVE files Sessions or L2 CLOSED/ARCHIVED Sessions; controlled same-Workspace cwd changes ship through the durable cwd_change operations (v1.32). The per-caller workspaceTurns capability on this surface advertises the same submit/rename admission rule; it does not gate cancellation. Freeze selection with the original idempotency key; admission does not prove physical directory readiness.",
"description": "Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission with public Session creation: the fixed server-owned profile for the built-in qwen-code agent, and the profile selected by a stored definition's single allowlisted hosted_profile tools entry for a definition-pinned Session. Any caller holding the OPERATOR role may submit later Turns and rename the Session under the same opt-in, while the Workspace registry row stays ACTIVE and still backs the binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is active and undeleted and is either a qwen-code Session on the frozen execution profile or pinned to a stored definition; Workspace close follows its separate close capability and lifecycle admission; the same caller may cancel a running Turn (its delivery re-checks no grant it needs survives the cancellation), and the Session's own liveness keeps OPERATOR access even when its Workspace's background-exit observation closes some other bound Session; cancel is always allowed for its own running Turn even outside the Workspace files opt-in. A definition-pinned Session keeps its pinned revision's tool profile, model and instructions for its whole life, and a replay of the create command reuses the original pin; a WebShell Session inherits its Workspace's default Workspace selection when the request omits one.",
"requestBody": {
"required": true,
"content": {
@ -5399,7 +5399,7 @@
"workspaceTurns": {
"type": "boolean",
"default": false,
"description": "True when the caller may submit later Turns of this Workspace-bound Session or rename it: the deployment enables Workspace files, the caller holds the OPERATOR role on a Workspace registry row whose state is ACTIVE and still backs the Session's binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is active and is either a qwen-code Session on the frozen execution profile or pinned to a stored definition. Cancelling only aborts work already running, so an OPERATOR caller may cancel a running Turn even when this is false; the capability does not gate cancellation. False for every other caller and for unbound Sessions, which do not use it."
"description": "True when the caller may submit later Turns of this Workspace-bound Session or rename it: the deployment enables Workspace files, the caller holds the OPERATOR role on a Workspace registry row whose state is ACTIVE and still backs the Session's binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is active and undeleted and is either a qwen-code Session or a Session pinned to a stored definition (both on the Workspace's frozen execution profile). Cancelling only aborts work already running, so an OPERATOR caller may cancel a running Turn even when this is false; the capability does not gate cancellation. False for every other caller and for unbound Sessions, which do not use it."
},
"tasks": {
"type": "boolean"

View file

@ -101,6 +101,15 @@ class ManagedAgentPropertiesTest {
.hasRootCauseMessage("Hosted Workspace files require"
+ " a supported Harness, Session Store and Session-isolated"
+ " local-process Broker with Workspace mounts"));
// A definition Session publishes instructions through the Session
// Store at Harness create, so the Harness implies the Store even
// when Workspace files are off.
context.withPropertyValues(
"qwen.managed-agent.harness.enabled=true",
"qwen.managed-agent.session-store.enabled=false")
.run(started -> assertThat(started).hasFailed()
.getFailure().hasRootCauseInstanceOf(IllegalStateException.class)
.hasRootCauseMessage("Hosted Harness requires the Managed Session Store"));
}
@Configuration(proxyBeanMethods = false)

View file

@ -816,6 +816,51 @@ class QwenHostedHarnessConnectorTest {
verify(client, times(2)).runMessageOperation(attached, stop);
}
// D8b: a definition-pinned child Session's message stop loads through
// the same entry, so the load must carry the pin or the Harness would
// refuse the Session with hosted_agent_definition_conflict.
@Test
void aMessageStopOfAPinnedSessionCarriesTheAgentDefinition() {
HostedHarnessClient client = mock(HostedHarnessClient.class);
HostedHarnessCapabilities capabilities = mock(HostedHarnessCapabilities.class);
HarnessSessionRef attached = mock(HarnessSessionRef.class);
SessionRecord session = new SessionRecord("tenant-a", SESSION_ID,
"agent_00112233445566778899aabbccddeeff", "2", null, "ACTIVE",
null, null, 0, 0, 0, 1, 1, null, 1,
new ContextBinding("tenant-a", "workspace", 1,
"storage", ".", "config", 1), "default",
"hosted-workspace-shell/1",
"a".repeat(64));
AgentStateStore sessions = mock(AgentStateStore.class);
WorkspaceExecutionStore execution = mock(WorkspaceExecutionStore.class);
when(client.capabilities()).thenReturn(capabilities);
when(capabilities.getBootId()).thenReturn(BOOT_ID);
when(sessions.requireSession("tenant-a", SESSION_ID)).thenReturn(session);
when(client.loadSession(any(LoadHarnessSession.class))).thenReturn(attached);
when(attached.getHarnessBootId()).thenReturn(BOOT_ID);
when(attached.getApprovalMode()).thenReturn("default");
ManagedAgentProperties properties = properties();
properties.getHarness().setWorkspaceFilesEnabled(true);
ManagedActionStore actions = mock(ManagedActionStore.class);
when(actions.approvalMode("tenant-a", SESSION_ID)).thenReturn("default");
QwenHostedHarnessConnector connector = new QwenHostedHarnessConnector(
properties, sessions, execution, actions);
ReflectionTestUtils.setField(connector, "client", client);
connector.runMessageOperation("tenant-a", SESSION_ID, Map.of(
"operationId", "66666666-6666-4666-8666-666666666666",
"kind", "stop"));
ArgumentCaptor<LoadHarnessSession> loads = ArgumentCaptor.forClass(LoadHarnessSession.class);
verify(client, times(1)).loadSession(loads.capture());
assertThat(ReflectionTestUtils.<Map<String, Object>>invokeMethod(
loads.getValue(), "toJson"))
.containsEntry("agentDefinition", Map.of(
"agentId", "agent_00112233445566778899aabbccddeeff",
"revision", "2",
"digest", "a".repeat(64)))
.containsEntry("stopMessages", true);
}
@Test
void channelOperationsReauthorizeTheWorkspaceLikeEveryNewWorkDispatch() {
HostedHarnessClient client = mock(HostedHarnessClient.class);

View file

@ -76,7 +76,7 @@ export interface paths {
};
get?: never;
put?: never;
/** @description Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. Any caller holding the OPERATOR role may submit later Turns and rename the Session under the same opt-in, while the Workspace registry row stays ACTIVE and still backs the binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is active and undeleted and is either a qwen-code Session on the frozen execution profile or pinned to a stored definition; Workspace close follows its separate close capability and lifecycle admission; the same caller may cancel a running Turn (its delivery re-checks no grants); archive and unarchive follow their separate retention capabilities after reliable Workspace close; delete supports either L3 idle ACTIVE files Sessions or L2 CLOSED/ARCHIVED Sessions; controlled same-Workspace cwd changes ship through the durable cwd_change operations (v1.32). The per-caller workspaceTurns capability on this surface advertises the same submit/rename admission rule; it does not gate cancellation. Freeze selection with the original idempotency key; admission does not prove physical directory readiness. */
/** @description Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission with public Session creation: the fixed server-owned profile for the built-in qwen-code agent, and the profile selected by a stored definition's single allowlisted hosted_profile tools entry for a definition-pinned Session. Any caller holding the OPERATOR role may submit later Turns and rename the Session under the same opt-in, while the Workspace registry row stays ACTIVE and still backs the binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is active and undeleted and is either a qwen-code Session on the frozen execution profile or pinned to a stored definition; Workspace close follows its separate close capability and lifecycle admission; the same caller may cancel a running Turn (its delivery re-checks no grant it needs survives the cancellation), and the Session's own liveness keeps OPERATOR access even when its Workspace's background-exit observation closes some other bound Session; cancel is always allowed for its own running Turn even outside the Workspace files opt-in. A definition-pinned Session keeps its pinned revision's tool profile, model and instructions for its whole life, and a replay of the create command reuses the original pin; a WebShell Session inherits its Workspace's default Workspace selection when the request omits one. */
post: operations["webShellCreateSession"];
delete?: never;
options?: never;
@ -546,7 +546,7 @@ export interface components {
/** @default false */
actions: boolean;
/**
* @description True when the caller may submit later Turns of this Workspace-bound Session or rename it: the deployment enables Workspace files, the caller holds the OPERATOR role on a Workspace registry row whose state is ACTIVE and still backs the Session's binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is active and is either a qwen-code Session on the frozen execution profile or pinned to a stored definition. Cancelling only aborts work already running, so an OPERATOR caller may cancel a running Turn even when this is false; the capability does not gate cancellation. False for every other caller and for unbound Sessions, which do not use it.
* @description True when the caller may submit later Turns of this Workspace-bound Session or rename it: the deployment enables Workspace files, the caller holds the OPERATOR role on a Workspace registry row whose state is ACTIVE and still backs the Session's binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is active and undeleted and is either a qwen-code Session or a Session pinned to a stored definition (both on the Workspace's frozen execution profile). Cancelling only aborts work already running, so an OPERATOR caller may cancel a running Turn even when this is false; the capability does not gate cancellation. False for every other caller and for unbound Sessions, which do not use it.
* @default false
*/
workspaceTurns: boolean;