test(managed-agent): ratchet persistence and runtime-broker class sizes (#16284)

The store decomposition (#16090, #16142) and the broker extraction cluster
turned the god classes into facades, but nothing stops them regrowing. Add
ClassSizeRatchetTest, the class-size gate the structure review left as an open
question, introduced now that the splits have landed. It pins every current
class >= 800 lines in persistence/runtime-broker at its present size
(ratchet-down-only), caps all classes at a global ceiling seeded at the current
maximum, forces any class reaching 800 lines to be pinned, and rejects stale
pins. Verification-only: no production code change. Each rule is mutation
verified; the bilingual design and the structure-review ledger (open question
resolved, acceptance criterion concretized) are updated in the same change.

Co-authored-by: verify-round <verify-round@local>
This commit is contained in:
Shaojin Wenandverify-round authored and GitHub committed 2026-11-04 16:46:05 +08:00
1 parent eefd4fd654
commit 30079d6c3d
6 files changed
+297 -6

No files matched your search

@@ -404,7 +404,8 @@ cross-cutting "how the system works today" tour.
`SessionCreationCommand`; the pin test is green and ratchets.
- `ManagedAgentStore` has no public setter/configure wiring; the two target
god classes are decomposed (P1 complete when no class in `persistence` or
`runtime-broker` exceeds the agreed size ratchet).
`runtime-broker` exceeds the agreed size ratchet, enforced by
`ClassSizeRatchetTest`).
- Zero OpenAPI diff, zero schema diff, all ArchUnit/Checkstyle/SpotBugs gates
green, CI full profiles green on the accepted revision.
- README and the architecture tour exist in both languages with reciprocal
@@ -418,6 +419,9 @@ cross-cutting "how the system works today" tour.
migrated call site uses it; the overload convergence and the builder landed
in the same change. Role-interface segregation (P0 step 3) follows as a
separate slice.
- Whether to introduce the class-size ratchet gate before P1 (to stop
regrowth) or only after the splits land.
- Resolved (2026-11-04): the class-size ratchet gate is introduced after the
splits land. `ClassSizeRatchetTest` pins the current large classes in
`persistence`/`runtime-broker` ratchet-down-only, with a global ceiling that
lowers as the remaining large classes are decomposed; see the
[class-size ratchet design](2026-11-04-managed-agent-class-size-ratchet.md).
- Sequencing of P1 slices against in-flight feature work on the same classes.
@@ -328,7 +328,7 @@ sweep 管理这个陷阱。各字段的单位本身是一份被钉住的绑定
`SessionCreationCommand`;pin 测试常绿且棘轮生效。
- `ManagedAgentStore` 不再有公开 setter/configure 装配;两个目标上帝类
完成分解(P1 完成的标志:`persistence` 与 `runtime-broker` 中没有任何类
超过约定的体量棘轮)。
超过约定的体量棘轮,由 `ClassSizeRatchetTest` 强制)。
- OpenAPI 零 diff、schema 零 diff,ArchUnit/Checkstyle/SpotBugs 门禁全绿,
被接受修订上 CI 完整 profile 全绿。
- README 与架构导览以双语存在并互链(P2)。
@@ -340,6 +340,8 @@ sweep 管理这个陷阱。各字段的单位本身是一份被钉住的绑定
- 已解决(2026-10-31):P0 附带具名构造 builder,全部迁移调用点均已
使用;重载收敛与 builder 在同一改动落地。角色接口拆分(P0 第 3 步)
作为独立切片跟进。
- 类体量棘轮门禁是在 P1 之前引入(阻止继续长大),还是在拆分落地之后
再引入。
- 已解决(2026-11-04):类体量棘轮门禁在拆分落地之后引入。
`ClassSizeRatchetTest` 将 `persistence`/`runtime-broker` 当前大类按只降不升
钉住,并设一个随剩余大类被分解而下调的全局上限;见
[类体量棘轮设计](2026-11-04-managed-agent-class-size-ratchet.zh-CN.md)。
- P1 切片与同批类上进行中的功能工作如何排序。
@@ -0,0 +1,54 @@
# Managed Agent class-size ratchet
[English](2026-11-04-managed-agent-class-size-ratchet.md) | [简体中文](2026-11-04-managed-agent-class-size-ratchet.zh-CN.md)
## Status and scope
- Status: implemented by this change; a verification-only gate, no production code change.
- Date: 2026-11-04
- Scope: `packages/managed-agent-server` — the `persistence` and `runtime-broker` production sources.
- Baseline: `origin/main` `14a5a2d98d`, after the store decomposition (#16090, #16142) and the broker extraction cluster landed.
This resolves the open question left by the [structure review](2026-10-31-managed-agent-structure-review.md): whether to introduce the class-size ratchet gate before P1 (to stop regrowth) or only after the splits land. The two headline god classes are now decomposed — `ManagedAgentStore` is a 1,170-line facade and `ManagedSessionStore` a 357-line facade, and `RuntimeBrokerService` is a 4,270-line facade over nine collaborators — so the gate is introduced **after the splits land**, to lock in those gains. The structure review's acceptance criteria already name this gate: "P1 complete when no class in `persistence` or `runtime-broker` exceeds the agreed size ratchet."
## Problem
The decomposition moved responsibilities out of the god classes, but nothing stops them from growing back. Without a gate, a future capability slice can quietly re-accumulate thousands of lines into `ManagedAgentStore` or one of its aggregates, undoing the split one convenience method at a time. The overload-count ratchet (`SessionCreationCommandContractTest`) protects the session-creation API shape, but not class size. A class-size ratchet makes regrowth a visible, failing change instead of silent drift.
## Design
A single verification test, `ClassSizeRatchetTest` (in the `architecture` package next to `ModuleArchitectureTest`), walks the production source trees of `persistence/src/main/java` and `runtime-broker/src/main/java`, counts source lines per top-level class, and enforces four rules against a pin resource, `class-size-ratchet.properties`:
1. **Global ceiling.** No class exceeds `GLOBAL_MAX_LINES` (4,270). This is the "agreed size ratchet" of the acceptance criteria, seeded at the current largest class (`RuntimeBrokerService`). It only ever lowers.
2. **Per-class ceiling.** Every class listed in the pin must have at most its pinned line count. The pin is seeded at each large class's current size, so a pinned class cannot grow at all.
3. **Large classes must be pinned.** Any class at or above `LARGE_CLASS_THRESHOLD` (800 lines) that is not in the pin fails. A class therefore cannot silently grow into a large one; crossing 800 lines forces a conscious pin at its then-current size.
4. **No stale pins.** Every pin entry must map to an existing production class. Deleting or renaming a class forces its pin to be pruned, keeping the pin honest.
Line counts count newline characters, matching `wc -l`; `package-info.java` and `module-info.java` are excluded. Source roots are resolved through `VerificationFixtures.moduleRoot()` (the `managed.module.root` property the build already injects), so the gate works in the reactor and in CI.
### Thresholds and why
- **`LARGE_CLASS_THRESHOLD = 800`.** A round line just below the smallest current large class (802). It captures every current aggregate and facade, and treats "reached 800 lines" as the point where a class becomes large enough to deserve an explicit, reviewed ceiling. Classes below it are free to change, so ordinary edits to small helpers do not trip the gate.
- **`GLOBAL_MAX_LINES = 4270`.** The tightest value that passes today (the current maximum). It is a backstop that no class — pinned or new — may exceed, and it ratchets down as the remaining large classes (`WorkspaceCsiApplicationInventory` 3,329, `ToolPublicationDataStore` 2,709, `JdbcRuntimeBindingRepository` 2,671, `ManagedExtensionRecordStore` 2,170, and the broker facade) are decomposed.
- **Scope is exactly `persistence` + `runtime-broker`.** These are the two modules the acceptance criteria name. The `runtime-*` provider modules (`runtime-http`/`host`/`local`/`external`/`kubernetes`/`ssh`/`static`) are separate top-level modules and out of scope; `contracts`, `control-plane`, `adapters`, and `server` are likewise not ratcheted here.
### Ratchet discipline
Ceilings only ever go **down**. When a class shrinks, lower its ceiling in the pin; when a class is deleted or renamed, remove its entry; lower `GLOBAL_MAX_LINES` as the large classes are split. Raising a ceiling or the global maximum is a visible diff that review rejects — the same convention as the overload-count ratchet. The pin header documents this discipline next to the entries.
## Verification
- The gate passes on the baseline: `ClassSizeRatchetTest` — `Tests run: 1, Failures: 0, Errors: 0` (JDK 21, isolated task repository). Checkstyle (which includes test sources) reports zero violations for the new test.
- Each rule is mutation-verified with a byte-level backup and sha256-verified restore:
- Lower `ManagedAgentStore`'s ceiling to 100 → fails: "grew to 1170 lines, above its ratchet ceiling 100" (rule 2).
- Remove `ManagedAgentStore`'s pin entry → fails: "reached 1170 lines (>= 800) but is not pinned" (rule 3).
- Add a pin for a nonexistent class → fails: "stale ratchet pin … no longer maps to a production class" (rule 4).
- Lower `GLOBAL_MAX_LINES` to 100 → fails: "… above the global ceiling 100" (rule 1).
- After restore, the gate is green again.
## Follow-up
- As each remaining large class is decomposed, lower its ceiling (or prune it) and lower `GLOBAL_MAX_LINES`; the structure-review ledger's acceptance criterion is met when no class exceeds the agreed ratchet.
- The gate deliberately does not auto-tighten pins; tightening is a reviewed edit, keeping the ratchet a conscious decision rather than a snapshot that silently drifts.
Related designs: [structure review](2026-10-31-managed-agent-structure-review.md), [store decomposition](2026-11-02-managed-store-decomposition.md).
@@ -0,0 +1,54 @@
# Managed Agent 类体量棘轮
[English](2026-11-04-managed-agent-class-size-ratchet.md) | [简体中文](2026-11-04-managed-agent-class-size-ratchet.zh-CN.md)
## 状态与范围
- 状态:由本改动实现;仅 verification 门禁,不改任何生产代码。
- 日期:2026-11-04
- 范围:`packages/managed-agent-server` —— `persistence` 与 `runtime-broker` 的生产源码。
- 基线:`origin/main` `14a5a2d98d`,store 分解(#16090、#16142)与 broker 提取簇落地之后。
本文解决[结构评审](2026-10-31-managed-agent-structure-review.zh-CN.md)留下的开放问题:类体量棘轮门禁是在 P1 之前引入(阻止再长),还是在拆分落地之后才引入。两个头号上帝类现已分解——`ManagedAgentStore` 是 1,170 行门面、`ManagedSessionStore` 是 357 行门面,`RuntimeBrokerService` 是九个协作者之上的 4,270 行门面——因此门禁在**拆分落地之后**引入,以锁住这些成果。结构评审的验收标准早已点名此门禁:"P1 完成=`persistence` 或 `runtime-broker` 中没有任何类超过约定的体量棘轮"。
## 问题
分解把职责移出了上帝类,但没有任何东西阻止它们长回去。没有门禁,未来的能力切片可以悄悄把数千行重新堆回 `ManagedAgentStore` 或它的某个聚合,一次一个便利方法地拆掉拆分成果。重载计数棘轮(`SessionCreationCommandContractTest`)保护的是会话创建 API 的形状,而非类体量。类体量棘轮让"再长"成为一个可见的、失败的改动,而不是静默漂移。
## 设计
单个 verification 测试 `ClassSizeRatchetTest`(置于 `architecture` 包、与 `ModuleArchitectureTest` 并列)遍历 `persistence/src/main/java` 与 `runtime-broker/src/main/java` 的生产源码树,按顶层类统计源码行数,并对照 pin 资源 `class-size-ratchet.properties` 强制四条规则:
1. **全局上限。** 任何类不得超过 `GLOBAL_MAX_LINES`(4,270)。这就是验收标准里的"约定体量棘轮",以当前最大类(`RuntimeBrokerService`)作种子,只降不升。
2. **按类上限。** pin 中列出的每个类,行数不得超过其钉住值。pin 以每个大类的当前行数作种子,故被钉住的类完全不能再长。
3. **大类必须入 pin。** 任何达到或超过 `LARGE_CLASS_THRESHOLD`(800 行)却不在 pin 中的类都会失败。因此一个类无法悄悄长成大类;跨过 800 行就强制按其当时行数显式入 pin。
4. **无失效 pin。** 每条 pin 必须对应一个存在的生产类。删除或重命名一个类就强制剪掉它的 pin,保持 pin 诚实。
行数按换行符计数,与 `wc -l` 一致;排除 `package-info.java` 与 `module-info.java`。源码根经 `VerificationFixtures.moduleRoot()`(构建已注入的 `managed.module.root` 属性)解析,故门禁在 reactor 与 CI 中均可用。
### 阈值及其理由
- **`LARGE_CLASS_THRESHOLD = 800`。** 一个略低于当前最小大类(802 行)的整数行界。它覆盖当前每个聚合与门面,并把"达到 800 行"视为一个类大到值得拥有一个显式、经评审的上限的临界点。低于它的类可自由改动,故对小 helper 的普通编辑不会触发门禁。
- **`GLOBAL_MAX_LINES = 4270`。** 今天能通过的最紧值(当前最大值)。它是一道任何类——无论是否入 pin——都不得超过的兜底,并随剩余大类(`WorkspaceCsiApplicationInventory` 3,329、`ToolPublicationDataStore` 2,709、`JdbcRuntimeBindingRepository` 2,671、`ManagedExtensionRecordStore` 2,170,以及 broker 门面)被分解而下调。
- **范围恰为 `persistence` + `runtime-broker`。** 这是验收标准点名的两个模块。`runtime-*` provider 模块(`runtime-http`/`host`/`local`/`external`/`kubernetes`/`ssh`/`static`)是独立顶层模块、不在范围;`contracts`、`control-plane`、`adapters`、`server` 同样不在此棘轮之列。
### 棘轮纪律
上限只**降**不升。一个类变小时,下调其在 pin 中的上限;一个类被删除或重命名时,移除其条目;随大类被拆分而下调 `GLOBAL_MAX_LINES`。上调某个上限或全局最大值是一个评审会拒绝的可见 diff——与重载计数棘轮同一约定。pin 的头注在条目旁记录了这一纪律。
## 验证
- 门禁在基线上通过:`ClassSizeRatchetTest` —— `Tests run: 1, Failures: 0, Errors: 0`(JDK 21,隔离任务仓库)。Checkstyle(含测试源码)对新测试报 0 违规。
- 每条规则都经字节级备份+sha256 校验还原的变异验证:
- 把 `ManagedAgentStore` 的上限降到 100 → 失败:"grew to 1170 lines, above its ratchet ceiling 100"(规则 2)。
- 删除 `ManagedAgentStore` 的 pin 条目 → 失败:"reached 1170 lines (>= 800) but is not pinned"(规则 3)。
- 为一个不存在的类加 pin → 失败:"stale ratchet pin … no longer maps to a production class"(规则 4)。
- 把 `GLOBAL_MAX_LINES` 降到 100 → 失败:"… above the global ceiling 100"(规则 1)。
- 还原后门禁重新转绿。
## 后续
- 每分解一个剩余大类,就下调其上限(或剪掉它)并下调 `GLOBAL_MAX_LINES`;当没有任何类超过约定棘轮时,结构评审账本的验收标准即达成。
- 门禁刻意不自动收紧 pin;收紧是一次经评审的编辑,让棘轮保持为有意识的决策,而非一份悄悄漂移快照。
相关设计:[结构评审](2026-10-31-managed-agent-structure-review.zh-CN.md)、[store 分解](2026-11-02-managed-store-decomposition.zh-CN.md)。
@@ -0,0 +1,138 @@
package com.alibaba.qwen.code.managedagent.architecture;
import com.alibaba.qwen.code.managedagent.VerificationFixtures;
import org.junit.jupiter.api.Test;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.TreeMap;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Ratchets production class size in {@code persistence} and
* {@code runtime-broker} so the decomposed stores and the broker facade cannot
* regrow into god classes. This is the class-size gate the structure review
* left as an open question; it is introduced now that the store decomposition
* (#16090, #16142) and the broker extraction cluster have landed.
*
* <p>Four rules are enforced against the source tree:
* <ol>
* <li>no class exceeds {@link #GLOBAL_MAX_LINES}, the agreed size ceiling;</li>
* <li>no pinned class exceeds its ceiling in
* {@code class-size-ratchet.properties};</li>
* <li>every class at or above {@link #LARGE_CLASS_THRESHOLD} lines is pinned,
* so a class cannot silently grow into a large one;</li>
* <li>every pin maps to an existing class (no stale pins).</li>
* </ol>
*
* <p>Ratchet discipline: ceilings only ever go down. When a class shrinks,
* lower its ceiling; when a class is deleted or renamed, remove its entry;
* {@link #GLOBAL_MAX_LINES} lowers as the remaining large classes are split.
* See {@code docs/design/2026-11-04-managed-agent-class-size-ratchet.md}.
*/
class ClassSizeRatchetTest {
private static final int LARGE_CLASS_THRESHOLD = 800;
private static final int GLOBAL_MAX_LINES = 4270;
private static final String PIN_RESOURCE = "/class-size-ratchet.properties";
private static final List<String> SOURCE_ROOTS =
List.of("persistence/src/main/java", "runtime-broker/src/main/java");
@Test
void productionClassesStayWithinTheirSizeRatchet() throws IOException {
Map<String, Integer> ceilings = loadPin();
Map<String, Integer> sizes = measureProductionClasses();
List<String> violations = new ArrayList<>();
for (Map.Entry<String, Integer> entry : sizes.entrySet()) {
String name = entry.getKey();
int lines = entry.getValue();
if (lines > GLOBAL_MAX_LINES) {
violations.add(name + " has " + lines + " lines, above the global ceiling "
+ GLOBAL_MAX_LINES + " (the ceiling only ratchets down)");
}
Integer ceiling = ceilings.get(name);
if (ceiling != null && lines > ceiling) {
violations.add(name + " grew to " + lines + " lines, above its ratchet ceiling "
+ ceiling + " (ceilings only go down; split the class instead)");
}
if (ceiling == null && lines >= LARGE_CLASS_THRESHOLD) {
violations.add(name + " reached " + lines + " lines (>= " + LARGE_CLASS_THRESHOLD
+ ") but is not pinned; add it to class-size-ratchet.properties"
+ " at its current size");
}
}
for (String pinned : ceilings.keySet()) {
if (!sizes.containsKey(pinned)) {
violations.add("stale ratchet pin " + pinned
+ " no longer maps to a production class; remove it");
}
}
assertThat(violations).as("class-size ratchet violations").isEmpty();
}
private Map<String, Integer> loadPin() throws IOException {
Properties properties = new Properties();
try (InputStream in = getClass().getResourceAsStream(PIN_RESOURCE)) {
assertThat(in).as("ratchet pin resource %s", PIN_RESOURCE).isNotNull();
properties.load(in);
}
Map<String, Integer> ceilings = new TreeMap<>();
for (String name : properties.stringPropertyNames()) {
ceilings.put(name, Integer.parseInt(properties.getProperty(name).trim()));
}
assertThat(ceilings).as("ratchet pin must not be empty").isNotEmpty();
return ceilings;
}
private Map<String, Integer> measureProductionClasses() throws IOException {
Path moduleRoot = VerificationFixtures.moduleRoot();
Map<String, Integer> sizes = new TreeMap<>();
for (String root : SOURCE_ROOTS) {
Path sourceRoot = moduleRoot.resolve(root);
assertThat(sourceRoot).as("source root %s", sourceRoot).isDirectory();
try (Stream<Path> files = Files.walk(sourceRoot)) {
files.filter(ClassSizeRatchetTest::isProductionClass).forEach(path -> {
String relative = sourceRoot.relativize(path).toString();
String name = relative
.substring(0, relative.length() - ".java".length())
.replace(File.separatorChar, '.');
sizes.put(name, countLines(path));
});
}
}
assertThat(sizes).as("measured production classes").isNotEmpty();
return sizes;
}
private static boolean isProductionClass(Path path) {
String fileName = path.getFileName().toString();
return fileName.endsWith(".java")
&& !fileName.equals("package-info.java")
&& !fileName.equals("module-info.java");
}
private static int countLines(Path path) {
try {
int lines = 0;
for (byte b : Files.readAllBytes(path)) {
if (b == '\n') {
lines++;
}
}
return lines;
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
@@ -0,0 +1,39 @@
# Class-size ratchet for the persistence and runtime-broker production sources.
#
# Each entry pins a class (fully-qualified name) to a maximum source-line count.
# ClassSizeRatchetTest enforces:
# 1. no class exceeds GLOBAL_MAX_LINES (the agreed size ceiling),
# 2. no pinned class exceeds its ceiling below,
# 3. every class at or above LARGE_CLASS_THRESHOLD (800 lines) is pinned,
# 4. every entry here maps to an existing class (no stale pins).
#
# Ratchet discipline: ceilings only ever go DOWN, never up. When a class
# shrinks, lower its ceiling here; when a class is deleted or renamed, remove
# its entry. A new class that reaches 800 lines must be added here at its then
# current size. GLOBAL_MAX_LINES ratchets down as the remaining large classes
# are decomposed. See docs/design/2026-11-04-managed-agent-class-size-ratchet.md.
#
# Seeded at origin/main 14a5a2d98d after the store decomposition (#16090,
# #16142) and the broker extraction cluster landed.
com.alibaba.qwen.code.managedagent.persistence.AgentExecutionOperations=1011
com.alibaba.qwen.code.managedagent.persistence.AgentLifecycleOperations=1055
com.alibaba.qwen.code.managedagent.persistence.AgentMessageProjection=959
com.alibaba.qwen.code.managedagent.persistence.AgentSessionAdmissions=889
com.alibaba.qwen.code.managedagent.persistence.AgentSessionCommands=1078
com.alibaba.qwen.code.managedagent.persistence.AgentTurnExecution=819
com.alibaba.qwen.code.managedagent.persistence.AutomationLedgerStore=820
com.alibaba.qwen.code.managedagent.persistence.ChildResultRelayStore=878
com.alibaba.qwen.code.managedagent.persistence.JournalCommit=880
com.alibaba.qwen.code.managedagent.persistence.ManagedActionStore=868
com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore=1170
com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionRecordStore=2170
com.alibaba.qwen.code.managedagent.persistence.ToolPublicationDataStore=2709
com.alibaba.qwen.code.managedagent.persistence.ToolPublicationStore=1031
com.alibaba.qwen.code.managedagent.persistence.WorkspaceCsiApplicationInventory=3329
com.alibaba.qwen.code.managedagent.persistence.WorkspaceExecutionStore=861
com.alibaba.qwen.code.managedagent.persistence.WorkspaceRecoveryStore=802
com.alibaba.qwen.code.managedagent.persistence.WorkspaceStorageGuard=943
com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiActivationAdmission=990
com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingRepository=2671
com.alibaba.qwen.code.runtimebroker.persistence.JdbcToolExecutionRepository=1235
com.alibaba.qwen.code.runtimebroker.service.RuntimeBrokerService=4270