feat(amsg): client_state 大值透明分块 + fire 级 scratch + /capabilities 特性探测#26
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces transparent chunking for client_state values exceeding 200KB, partial batch failure handling, a mutable scratch context for hook execution, and a new /capabilities endpoint for feature detection. The reviewer feedback highlights several important improvements: handling non-200/non-404 errors robustly in the client SDK's getCapabilities to avoid false negatives, parallelizing decryption in resolveClientStateEntries to eliminate sequential await bottlenecks, parsing maxStateValueBytes to support string-configured environment variables, and fixing a fragile headers fallback in the capabilities handler.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if (res.status === 404) return null; | ||
|
|
||
| let json; | ||
| try { | ||
| json = await res.json(); | ||
| } catch { | ||
| return null; | ||
| } |
There was a problem hiding this comment.
If the server returns a non-200 and non-404 HTTP status (such as a temporary 502 Bad Gateway or 503 Service Unavailable HTML page), res.json() will throw an error. Currently, this error is caught and the method silently returns null. This can mislead the client into thinking the worker is outdated (since null indicates the worker predates the endpoint), prompting incorrect UI redeployment hints. We should check the HTTP status and throw an error if it is not 200 or 404.
if (res.status === 404) return null;
let json;
try {
json = await res.json();
} catch {
if (res.status !== 200) {
throw new Error('Request failed with status ' + res.status);
}
return null;
}| export async function resolveClientStateEntries(rows, fetchChunkRows, decryptValue) { | ||
| let chunkMap = null; | ||
| const loadChunks = async () => { | ||
| if (chunkMap === null) { | ||
| const chunkRows = await fetchChunkRows(); | ||
| chunkMap = new Map(chunkRows.map((row) => [row.key, row])); | ||
| } | ||
| return chunkMap; | ||
| }; | ||
|
|
||
| const entries = []; | ||
| for (const row of rows) { | ||
| const count = parseChunkedRootCount(row.value); | ||
| if (count === null) { | ||
| entries.push({ | ||
| namespace: row.namespace, | ||
| key: row.key, | ||
| value: await decryptValue(row.value), | ||
| updatedAt: row.updated_at, | ||
| }); | ||
| continue; | ||
| } | ||
|
|
||
| const map = await loadChunks(); | ||
| const chunkRows = []; | ||
| let intact = true; | ||
| for (let i = 0; i < count; i++) { | ||
| const chunk = map.get(chunkKeyFor(row.key, i)); | ||
| if (!chunk || chunk.updated_at !== row.updated_at) { | ||
| intact = false; | ||
| break; | ||
| } | ||
| chunkRows.push(chunk); | ||
| } | ||
| if (!intact) continue; // 写到一半断了 → 该 key 视为不存在 | ||
|
|
||
| const parts = await Promise.all(chunkRows.map((chunk) => decryptValue(chunk.value))); | ||
| entries.push({ | ||
| namespace: row.namespace, | ||
| key: row.key, | ||
| value: parts.join(''), | ||
| updatedAt: row.updated_at, | ||
| }); | ||
| } | ||
| return entries; | ||
| } |
There was a problem hiding this comment.
There are two main areas of improvement in resolveClientStateEntries:
- Performance (Sequential Await): The current implementation sequentially awaits
decryptValuefor each non-chunked row inside theforloop. For namespaces with multiple keys, this introduces a sequential bottleneck. We can decrypt all rows in parallel usingPromise.all. - Robustness (Defensive Programming): If a custom or buggy database adapter returns
nullorundefinedforrowsorchunkRows, the function will crash with aTypeError. We should defensively ensure they are arrays before processing.
export async function resolveClientStateEntries(rows, fetchChunkRows, decryptValue) {
const safeRows = Array.isArray(rows) ? rows : [];
let chunkMap = null;
const loadChunks = async () => {
if (chunkMap === null) {
const chunkRows = await fetchChunkRows();
const safeChunkRows = Array.isArray(chunkRows) ? chunkRows : [];
chunkMap = new Map(safeChunkRows.map((row) => [row.key, row]));
}
return chunkMap;
};
const promises = safeRows.map(async (row) => {
const count = parseChunkedRootCount(row.value);
if (count === null) {
const decryptedValue = await decryptValue(row.value);
return {
namespace: row.namespace,
key: row.key,
value: decryptedValue,
updatedAt: row.updated_at,
};
}
const map = await loadChunks();
const chunkRows = [];
let intact = true;
for (let i = 0; i < count; i++) {
const chunk = map.get(chunkKeyFor(row.key, i));
if (!chunk || chunk.updated_at !== row.updated_at) {
intact = false;
break;
}
chunkRows.push(chunk);
}
if (!intact) return null;
const parts = await Promise.all(chunkRows.map((chunk) => decryptValue(chunk.value)));
return {
namespace: row.namespace,
key: row.key,
value: parts.join(''),
updatedAt: row.updated_at,
};
});
const results = await Promise.all(promises);
return results.filter((entry) => entry !== null);
}| const maxValueBytes = Number.isInteger(ctx.maxStateValueBytes) && ctx.maxStateValueBytes > 0 | ||
| ? ctx.maxStateValueBytes | ||
| : DEFAULT_MAX_STATE_VALUE_BYTES; |
There was a problem hiding this comment.
In Cloudflare Workers, environment variables are often injected as strings. If a deployer configures maxStateValueBytes via an environment variable (e.g., env.MAX_STATE_VALUE_BYTES) and passes it directly, Number.isInteger(ctx.maxStateValueBytes) will evaluate to false because it is a string. This will cause the custom limit to be silently ignored and fall back to DEFAULT_MAX_STATE_VALUE_BYTES. We should parse string values to integers to make the configuration robust.
| const maxValueBytes = Number.isInteger(ctx.maxStateValueBytes) && ctx.maxStateValueBytes > 0 | |
| ? ctx.maxStateValueBytes | |
| : DEFAULT_MAX_STATE_VALUE_BYTES; | |
| const rawLimit = ctx.maxStateValueBytes; | |
| const parsedLimit = typeof rawLimit === 'string' ? parseInt(rawLimit, 10) : rawLimit; | |
| const maxValueBytes = Number.isInteger(parsedLimit) && parsedLimit > 0 | |
| ? parsedLimit | |
| : DEFAULT_MAX_STATE_VALUE_BYTES; |
| async function GET(url, headers) { | ||
| const effectiveHeaders = headers || url || {}; | ||
| const tenantResult = await ctx.tenantManager.resolveTenant(effectiveHeaders); |
There was a problem hiding this comment.
The current assignment const effectiveHeaders = headers || url || {}; is fragile. If headers is undefined (e.g., if GET is called with only the url argument), effectiveHeaders will fall back to url, which is a string. Passing a string URL to resolveTenant (which expects a headers object) will cause runtime errors or fail to resolve the tenant. We should explicitly check the types of headers and url to handle both GET(url, headers) and GET(headers) signatures safely.
async function GET(url, headers) {
const effectiveHeaders = (headers && typeof headers === 'object')
? headers
: (url && typeof url === 'object' ? url : {});
const tenantResult = await ctx.tenantManager.resolveTenant(effectiveHeaders);
实机验证暴露的三个通用缺口补齐(任务 4 溢出封套经评估本批暂缓,评估见 plan 文末)。
改了什么 / 改后怎样
① client_state 大值透明分块 + 整批局部失败(amsg-server)
GET/readState()返回拼好的原值,客户端和 hook 作者无感;总上限默认 5MB(maxStateValueBytes可调)data.rejected逐条给原因;全成功时响应形状不变(老客户端无感)分块是服务端实现细节:切片在码点边界(中文 / emoji 不劈开)、缩块覆盖写不残留尾块、块不齐全时该 key 视为不存在(读方走兜底、不吐半截数据)、LWW 语义对分块路径同样成立。≤ 200KB 的值走原单行路径,存储字节级不变。
② fire 级 scratch(amsg-shared + amsg-server)
onBeforeFire的 fireCtx 和同一次 fire 每轮onLLMOutput/executeToolCalls的 sessionCtx 拿到同一个scratch对象引用,fire 结束(finish / skip-push / 抛错 / 轮数超限)即丢弃。宿主不用再自己维护Map<sessionId, state>+ 孤儿清理。不落库、不进日志、不跨 fire 共享。③
GET /capabilities+getCapabilities()(amsg-server + amsg-client)worker 部署版本落后时,新功能从「静默失效」变成「探测得到落后」:端点返回
{ serverVersion, features }(版本号构建期注入,feature 名中性、随版本追加),客户端getCapabilities()打到老 worker(404)返回null不抛错,前端可据此亮「worker 需要重新部署」的牌子。验收
__AMSG_SERVER_VERSION__构建期替换无残留实现计划(含任务 4 评估结论):
docs/superpowers/plans/2026-07-18-amsg-universal-gaps.mdhttps://claude.ai/code/session_012cnJAsyCLcxDH5UV5n3jgT