鸿蒙HarmonyOS Agent 状态管理与持久化实战 —— 原子写入、版本迁移与配对不变量
一、前言:状态持久化的"七个难题"
假设你在鸿蒙上做了一个 Agent 聊天应用,对话历史有几十轮,用户用着用着切到后台看了眼微信,回来发现:
-
对话历史丢了——进程被系统杀了,内存里的消息数组烟消云散。
-
状态写坏了——你上次保存时正好写到一半进程被杀,状态文件变成半个 JSON,下次读直接报错。
-
运行标志错乱——你上次置了
isRunning=true还没来得及置回 false 就被杀了,重启后 Agent 认为自己还在跑,但实际上网络连接早断了。 -
工具调用半截——模型刚返回一个 tool call 还没执行完就被杀了,持久化的历史里有个"只有调用没有结果"的孤儿 tool call,下次发给模型,模型懵了。
-
版本对不上——你升级了 App 加了新字段,旧版本写的状态文件新版本读不懂;或者用户从新版本降级回旧版本,新格式读不了。
-
路径被穿越——用户给的 sessionId 是
../../other,你的代码直接拼路径写文件,把别的会话文件覆盖了。 -
密钥泄露——你想"方便",把网络 Client 对象一起序列化进了状态文件,结果 token、cookie 全进了明文 JSON。
这七个问题不是独立的——原子写入影响崩溃恢复,版本迁移影响跨版本兼容,配对不变量影响发给模型的合法性,路径防护影响安全,密钥隔离影响底线。你需要的是一个端到端的状态持久化体系,而不是七个补丁。
ArkAgent 的状态体系定义在 agent_core/src/main/ets/state/ 下,核心由四个文件构成:AgentState.ets(状态对象 + 配对不变量)、StateStorage.ets(存储 SPI + 原子文件实现)、AgentStateCodec.ets(版本化编解码)、PendingApproval.ets / PendingPlanReview.ets(可序列化的冻结批次)。本文就拆解这套体系。
二、AgentState:显式 schema 与"绝不入盘"铁律
先看地基——状态对象本身的设计。这是 ADR-0010 冻结的核心决策。
2.1 一条贯穿全文的铁律
打开 AgentState.ets 的类注释,第一眼就是一句强硬的声明:
/**
* Serializable agent session snapshot. Implements ReadonlyAgentState for ToolContext.
* Never contains Client, Executor, Hook, listeners, cancel objects, or secrets.
*/
export class AgentState implements ReadonlyAgentState {
这条铁律把状态对象能装的东西限定死了。哪些能入盘、哪些绝不能入盘,对照如下:
|
类别 |
能否入盘 |
原因 |
|---|---|---|
|
|
❌ 绝不 |
持有 token、cookie、连接池,序列化即泄露密钥 |
|
|
❌ 绝不 |
持有业务对象、回调闭包,不可序列化 |
|
|
❌ 绝不 |
是函数引用,序列化无意义 |
|
监听器 listeners |
❌ 绝不 |
回调闭包,重启后失效 |
|
cancel 对象(AbortController 类) |
❌ 绝不 |
持有底层句柄,重启后无效 |
|
secrets(密钥) |
❌ 绝不 |
明文 JSON,直接泄露 |
|
|
✅ 可以 |
纯数据 |
|
|
✅ 可以 |
纯 JSON |
|
|
✅ 可以 |
但只表示"可恢复"不表示"连接存活" |
|
|
✅ 可以 |
纯数据快照 |
这条铁律的核心判断是:状态对象只装数据,不装运行时句柄。运行时句柄属于 Runtime,状态对象属于存储。两者生命周期完全不同——Runtime 在进程内创建销毁,状态对象在进程间穿越。
2.2 字段全景:从 v1 到 v3 的演进
AgentState 当前 schemaVersion=3,字段如下(节选自源码):
export class AgentState implements ReadonlyAgentState {
schemaVersion: number // ← 显式版本,从 1 起步
sessionId: string // ← 会话唯一标识
history: MessageHistory // ← messages + episodicMemories
usages: ModelUsage[] // ← 累计 token 用量
metadata: JsonObject // ← 业务自由字段
plan?: JsonObject // ← 当前计划(可选)
activeSkills?: string[] // ← 激活技能(可选)
isRunning: boolean // ← 可恢复标志(非连接存活)
totalLoopCount: number // ← 总循环数
currentLoopCount: number // ← 当前循环数
currentLoopUsages: ModelUsage[] // ← 当前循环用量
lastError?: string // ← 最后错误
systemReminders: Map<string, string> // ← 系统提示注入
systemPromptHistory: SystemPromptHistoryItem[] // ← prompt 变更历史
toolsHistory: ToolsHistoryItem[] // ← 工具定义变更历史
pendingRunCheckpoint?: PendingRunCheckpoint // ← 中断元数据
pendingApproval?: PendingApprovalBatch // ← v2+ 危险工具审批冻结
pendingPlanReview?: PendingPlanReview // ← v3+ 计划评审冻结
planReviewApproved: boolean // ← v3+ 评审通过标志
三个版本的演进清晰可循:
|
版本 |
引入字段 |
动机 |
|---|---|---|
|
v1 |
基础字段(messages/usages/metadata/运行计数/lastError/reminders/history) |
最小可用快照 |
|
v2 |
|
支持危险工具审批的跨重启续传 |
|
v3 |
|
支持 PlanMode.review 的跨重启续传 |
为什么要从一开始就带 schemaVersion?因为 AgentState 会跨应用版本长期存在——用户今天装的 v1 写的状态文件,三个月后升级到 v3 还要能读。没有版本号,你根本不知道读到的是哪个年代的格式。ADR-0010 的原话是:"所有 State 根对象携带 schemaVersion;从 v1 开始。"
2.3 MessageHistory:对话历史 + 情景记忆槽
history 不是裸的消息数组,而是一个复合对象:
export class MessageHistory {
messages: AgentMessage[] // ← 对话主历史
episodicMemories: EpisodicMemory[] // ← 情景记忆槽(业务逻辑在阶段 7)
constructor(messages: AgentMessage[] = [], episodicMemories: EpisodicMemory[] = []) {
this.messages = messages
this.episodicMemories = episodicMemories
}
clone(): MessageHistory {
// 深拷贝 messages 和每个 episode 的 messages
// ...
}
}
messages 是发给模型的主对话历史;episodicMemories 是预留的"情景记忆"槽位(类似长期记忆摘要),业务逻辑在后续阶段填充,但结构从 v1 就固化进 schema,避免后续破坏性改动。
2.4 snapshot():只读快照的"深拷贝到什么程度"
snapshot() 提供一个只读快照给外部观察者(如 ToolContext)。它的拷贝策略是"深拷贝可变集合,共享不可变值":
snapshot(): AgentState {
const copy = new AgentState(this.sessionId, this.schemaVersion)
copy.history = this.history.clone() // ← 可变集合,深拷贝
copy.usages = this.usages.slice() // ← 数组,浅拷贝足够(元素不可变)
copy.metadata = this.metadata.copy() // ← 可变 JsonObject,深拷贝
if (this.plan !== undefined) {
copy.plan = this.plan.copy() // ← 可变,深拷贝
}
if (this.activeSkills !== undefined) {
copy.activeSkills = this.activeSkills.slice() // ← 字符串数组,浅拷贝
}
// ... 其余字段同理
this.systemReminders.forEach((value: string, key: string) => {
copy.systemReminders.set(key, value) // ← Map,逐项重建
})
// pendingRunCheckpoint / pendingApproval / pendingPlanReview 都是值对象,深拷贝
// ...
return copy
}
这里有个关键判断:Message 对象是共享的。因为 AgentMessage 是不可变的域值(domain value),共享引用不会导致外部修改污染内部状态。但 messages 数组本身是可变的,所以数组要拷贝。这种"浅拷贝数组 + 共享不可变元素"的策略,在保证安全的同时避免了无谓的深拷贝开销。
三、StateStorage SPI:tmp→bak→main 三段式原子写入
状态对象设计好了,接下来是怎么存。ArkAgent 抽象了一个 StateStorage SPI,把"存什么"和"怎么存"解耦。
3.1 SPI 接口:六个方法
export interface StateStorage {
loadOrCreate(sessionId: string, initialMetadata?: JsonObject): Promise<AgentState>
load(sessionId: string): Promise<AgentState>
save(state: AgentState): Promise<void>
delete(sessionId: string): Promise<void>
exists(sessionId: string): Promise<boolean>
/**
* Salvage marker: next load forces isRunning=false even if the last full
* snapshot still has isRunning=true (terminal full-save failed).
* Must not overwrite the last valid full snapshot.
*/
markNotResumable(sessionId: string): Promise<void>
}
前五个方法很常规,重点是 markNotResumable——它不是删除状态,而是打一个小标记,下次 load 时强制 isRunning=false。为什么需要它?场景是:Agent 跑到终态(complete/cancel/error),想保存终态快照但写盘失败了。这时如果不打标记,下次 load 会读到"上一个 isRunning=true 的中间快照",误以为还能恢复。打了标记后,load 会强制 isRunning=false + 清掉 pendingRunCheckpoint,诚实承认"这个会话不可恢复,但历史还在"。
两个实现:
|
实现 |
用途 |
特点 |
|---|---|---|
|
|
单元测试、纯内存场景 |
实例隔离,无持久化 |
|
|
生产环境、鸿蒙沙箱文件 |
原子写入、bak 回退、路径防护 |
3.2 FileStateStorage 的文件布局
FileStateStorage 用四个文件协同,对应一个 sessionId:
{baseDir}/{safeSessionId}.json ← 主文件(main)
{baseDir}/{safeSessionId}.json.bak ← 备份(bak,上一次的有效快照)
{baseDir}/{safeSessionId}.json.tmp ← 临时文件(tmp,正在写)
{baseDir}/{safeSessionId}.json.ended ← 不可恢复标记(小标记,不覆盖快照)
.ended 是个小标记文件(内容就是字符串 '1'),它的设计哲学是:绝不覆盖最后一个完整快照。如果终态全量保存失败,我们宁愿留下"中间快照 + 不可恢复标记",也不愿"什么都没有"或"覆盖成空文件"。
3.3 原子写入:tmp→bak→main 三段式
save 的核心是这三段式替换。完整源码(精简注释后):
async save(state: AgentState): Promise<void> {
const sessionId = state.sessionId
safeSessionFileName(sessionId) // ← 提前校验 sessionId 合法性
const main = this.mainPath(sessionId)
const bak = this.bakPath(sessionId)
const tmp = this.tmpPath(sessionId)
const ended = this.endedPath(sessionId)
const encoded = AgentStateCodec.encode(state)
const content = JsonCodec.stringify(JsonValue.object(encoded))
try {
await this.fs.mkdirp(this.baseDir)
// 第一段:写临时文件,写完前绝不碰 main
await this.fs.writeText(tmp, content)
const mainExists = await this.fs.exists(main)
if (mainExists) {
// 第二段:把当前 main 提升为 bak(先删旧 bak)
const bakExists = await this.fs.exists(bak)
if (bakExists) {
await this.fs.delete(bak)
}
await this.fs.rename(main, bak)
}
// 第三段:tmp 升正为 main
await this.fs.rename(tmp, main)
// 全量保存成功,作废 ended 标记
if (await this.fs.exists(ended)) {
await this.fs.delete(ended)
}
} catch (error) {
// 失败时:尽力清理 tmp,但绝不碰 main/bak
try {
if (await this.fs.exists(tmp)) {
await this.fs.delete(tmp)
}
} catch (_cleanup) {
// 忽略清理错误
}
// 抛出 saveFailed 错误
// ...
}
}
三段式的精髓在于任何时刻 main 都是完整的:
|
阶段 |
main |
bak |
tmp |
崩溃后状态 |
|---|---|---|---|---|
|
写 tmp 前 |
旧(完整) |
— |
— |
load main,正常 |
|
写 tmp 中 |
旧(完整) |
— |
半个 |
load main 正常;tmp 被下次清理 |
|
写完 tmp,提升 bak 前 |
旧(完整) |
— |
新(完整) |
load main 正常;tmp 升正或清理 |
|
bak 提升后,tmp 升正前 |
— |
旧(完整) |
新(完整) |
load bak 回退;tmp 升正或清理 |
|
tmp 升正后 |
新(完整) |
旧(完整) |
— |
load main,正常 |
最后一行是理想终态:新 main + 旧 bak 双保险。倒数第二行是唯一"危险窗口"(main 暂时缺失),但此时 bak 还在,load 会自动回退到 bak。没有任何一个时刻 main 是半个文件——这就是原子性的保证。
3.4 load 容错:主坏读 bak,双坏报错
load 的容错策略是"main 优先,坏则读 bak,都坏则报 corrupt"。但有一个例外:schema_version_unsupported 硬拒绝,不回退 bak。源码核心:
async load(sessionId: string): Promise<AgentState> {
const main = this.mainPath(sessionId)
const bak = this.bakPath(sessionId)
const mainExists = await this.fs.exists(main)
if (mainExists) {
try {
return await this.loadFromPath(main, sessionId)
} catch (mainError) {
// ⚠️ 关键:未来版本不回退 bak
if (mainError instanceof ArkAgentError &&
mainError.code === 'schema_version_unsupported') {
throw mainError
}
const bakExists = await this.fs.exists(bak)
if (!bakExists) {
// 没有 bak,直接抛 main 的错误
if (mainError instanceof ArkAgentError) {
throw mainError
}
throw StorageErrors.corrupt(sessionId, /* ... */)
}
try {
return await this.loadFromPath(bak, sessionId)
} catch (bakError) {
// bak 也是不支持的版本,同样硬拒绝
if (bakError instanceof ArkAgentError &&
bakError.code === 'schema_version_unsupported') {
throw bakError
}
throw StorageErrors.corrupt(sessionId,
`main and bak corrupt: ${/* main 错误 */}; ${/* bak 错误 */}`)
}
}
}
// main 不存在,直接尝试 bak
// ...
throw StorageErrors.notFound(sessionId)
}
容错的决策矩阵:
|
main 状态 |
bak 状态 |
行为 |
|---|---|---|
|
完整 |
任意 |
load main |
|
损坏(JSON/decode 失败) |
完整 |
load bak |
|
损坏 |
损坏/不存在 |
抛 corrupt |
|
完整但 schema 版本不支持 |
任意 |
硬拒绝,不读 bak |
|
损坏 |
完整但 schema 版本不支持 |
硬拒绝 |
|
不存在 |
完整 |
load bak |
|
不存在 |
不存在 |
抛 notFound |
为什么"版本不支持"要硬拒绝不回退?这是阶段 4 的踩坑 6——下文专门讲。
3.5 markNotResumable:诚实的小标记
markNotResumable 的实现极简:
async markNotResumable(sessionId: string): Promise<void> {
safeSessionFileName(sessionId)
const ended = this.endedPath(sessionId)
try {
await this.fs.mkdirp(this.baseDir)
// 只写一个小标记,不覆盖最后的完整快照
await this.fs.writeText(ended, '1')
} catch (error) {
// ...
}
}
loadFromPath 在读到状态后,会检查 .ended 是否存在:
private async loadFromPath(path: string, sessionId: string): Promise<AgentState> {
// ... 读取并解码 ...
const state = decoded.value
if (await this.fs.exists(this.endedPath(sessionId))) {
state.isRunning = false // ← 强制不可恢复
state.pendingRunCheckpoint = undefined // ← 清掉中断元数据
}
return state
}
这个设计的精妙之处:它不删快照,只改语义。历史消息、用量、计划都还在,但明确告诉调用方"这个会话不能 resume,只能查看"。这比"删掉状态假装没发生过"或"保留 isRunning=true 让用户以为能续跑"都更诚实。
四、路径遍历防护:sessionId 永远当数据,不当路径
sessionId 是用户/业务给的字符串,绝不能直接拼进文件路径。一旦 sessionId 含 ../ 或 /,攻击者能读写任意文件。ArkAgent 用 safeSessionFileName 做白名单清洗。
4.1 safeSessionFileName 的三道防线
export function safeSessionFileName(sessionId: string): string {
// 第一道:拒绝空
if (sessionId.length === 0) {
throw StorageErrors.invalidSessionId(sessionId)
}
// 第二道:拒绝危险字符(..、/、\、\0)
if (sessionId.indexOf('..') >= 0 || sessionId.indexOf('/') >= 0 ||
sessionId.indexOf('\\') >= 0 || sessionId.indexOf('\0') >= 0) {
throw StorageErrors.invalidSessionId(sessionId)
}
// 第三道:白名单替换(非字母数字/-_/. 替换为 _)
let safe = ''
for (let i = 0; i < sessionId.length; i++) {
const ch = sessionId.charAt(i)
const code = sessionId.charCodeAt(i)
const isAlphaNum = (code >= 48 && code <= 57) || // 0-9
(code >= 65 && code <= 90) || // A-Z
(code >= 97 && code <= 122) // a-z
if (isAlphaNum || ch === '-' || ch === '_' || ch === '.') {
safe += ch
} else {
safe += '_' // ← 控制字符、空格、Unicode 全替换
}
}
// 兜底:替换后若变成 . 或 .. 或空,仍拒绝
if (safe === '.' || safe === '..' || safe.length === 0) {
throw StorageErrors.invalidSessionId(sessionId)
}
return safe
}
三道防线的分工:
|
防线 |
作用 |
拦截示例 |
|---|---|---|
|
第一道:非空 |
拒绝空 sessionId |
|
|
第二道:危险字符黑名单 |
拒绝路径分隔符和穿越符 |
|
|
第三道:白名单替换 |
把控制字符、空格、Unicode 替换成 |
|
|
兜底:结果校验 |
防止替换后变成 |
|
为什么需要第三道?因为前两道只拦了 ASCII 路径分隔符,但 sessionId 可能含控制字符(如换行 \n,在某些文件系统上会出问题)、空格、Unicode 字符。第三道把这些全部替换成安全的 _。比如 sessionId 是 "烘焙会话-001",会被清洗成 "____-001"(汉字变 _,保留 - 和数字)。
这个函数在三个地方被调用,构成"早校验"策略:
// save 开头
async save(state: AgentState): Promise<void> {
const sessionId = state.sessionId
safeSessionFileName(sessionId) // ← 写之前就校验
// ...
}
// markNotResumable 开头
async markNotResumable(sessionId: string): Promise<void> {
safeSessionFileName(sessionId) // ← 写之前就校验
// ...
}
// 所有路径生成方法内部
private mainPath(sessionId: string): string {
return `${this.baseDir}/${safeSessionFileName(sessionId)}.json` // ← 每次都清洗
}
4.2 StorageErrors.redact:日志脱敏
sessionId 是敏感信息(可能含用户标识),日志里不能全打。StorageErrors.redact 只显示前 8 个字符:
static redact(sessionId: string): string {
if (sessionId.length <= 8) {
return sessionId
}
return `${sessionId.substring(0, 8)}...`
}
所有存储错误都用 redact 处理 sessionId:
static notFound(sessionId: string): ArkAgentError {
return new ArkAgentError(ErrorLayer.storage, 'session_not_found',
`Session not found: ${StorageErrors.redact(sessionId)}`, ErrorRetryability.never)
}
四种存储错误码对照:
|
错误码 |
触发场景 |
可重试性 |
|---|---|---|
|
|
load 时 main/bak 都不存在 |
never |
|
|
JSON 解析失败、decode 失败、main 和 bak 都坏 |
never |
|
|
writeText/rename 抛异常 |
conditional(可能临时) |
|
|
sessionId 空/含路径穿越符 |
never |
|
|
未来版本,无法识别 |
never |
schema_version_unsupported 是个特殊的错误码——它不在 StorageErrors 里生成,而在 AgentStateCodec.decode 里生成,但 loadFromPath 会原样透传(保留原始 code),不把它包装成 state_corrupt。这就是为了支持 load 层"硬拒绝不回退"的判断。
五、AgentStateCodec:版本迁移与白名单编解码
状态对象的序列化由 AgentStateCodec 负责,它是一个严格白名单编解码器。
5.1 encode:永远写当前版本
static readonly CURRENT_VERSION: number = 3
static encode(state: AgentState): JsonObject {
const history = new JsonObject()
.set('messages', JsonValue.array(state.history.messages.map((m: AgentMessage) =>
JsonValue.object(DomainCodec.encodeMessageForPersistence(m)))))
.set('episodicMemories', JsonValue.array(state.history.episodicMemories.map(
(ep: EpisodicMemory) => JsonValue.object(AgentStateCodec.encodeEpisode(ep)))))
const object = new JsonObject()
.set('schemaVersion', JsonValue.number(AgentStateCodec.CURRENT_VERSION)) // ← 永远写 3
.set('sessionId', JsonValue.string(state.sessionId))
.set('history', JsonValue.object(history))
// ... 其余字段
// 可选字段按需写入(undefined 不写)
if (state.plan !== undefined) {
object.set('plan', JsonValue.object(state.plan))
}
// ...
return object
}
两个要点:永远写 CURRENT_VERSION(不管 state 对象里的 schemaVersion 是几,encode 出来都是 3),可选字段 undefined 不写入(节省空间,decode 时缺失即默认值)。
注意消息持久化用的是 DomainCodec.encodeMessageForPersistence 而非普通的 encodeMessage——前者会剥离二进制媒体(图片、音频),把不可发送的媒体变成文本占位符。这保证状态文件里没有二进制 blob,且"线上永远不信任 public 的 metadata 标志"。
5.2 decode:版本校验 + 白名单 + 未知字段忽略
decode 是 encode 的逆过程,但多了三重校验:
static decode(object: JsonObject): Result<AgentState, ArkAgentError> {
// 第一重:读 schemaVersion
const versionResult = AgentStateCodec.readSchemaVersion(object)
if (!versionResult.ok || versionResult.value === undefined) {
return Result.failure<AgentState, ArkAgentError>(versionResult.error as ArkAgentError)
}
const version = versionResult.value
// 第二重:未来版本硬拒绝
if (version > AgentStateCodec.CURRENT_VERSION) {
return Result.failure<AgentState, ArkAgentError>(new ArkAgentError(
ErrorLayer.storage, 'schema_version_unsupported',
`Unsupported AgentState schemaVersion ${version}; current is ${AgentStateCodec.CURRENT_VERSION}`,
ErrorRetryability.never))
}
// 第三重:逐字段白名单解码
// legacy v0(无 version)和 v1 共享相同字段布局(迁移后)
const sessionId = Decode.requiredString(object, 'sessionId')
// ...
}
版本读取的兼容策略:
private static readSchemaVersion(object: JsonObject): Result<number, ArkAgentError> {
if (!object.has('schemaVersion')) {
// 早期无版本状态 → legacy v0
return Result.success<number, ArkAgentError>(0)
}
const value = object.get('schemaVersion')?.asNumber()
if (value === undefined || !Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
return Result.failure<number, ArkAgentError>(ArkAgentError.decode(
'schema_version_invalid', 'schemaVersion must be non-negative integer'))
}
return Result.success<number, ArkAgentError>(value)
}
版本迁移的整体策略:
|
输入版本 |
行为 |
说明 |
|---|---|---|
|
0(无 schemaVersion 字段) |
当作 v0,按当前布局解码,缺失字段填默认 |
兼容早期无版本状态 |
|
1 |
按当前布局解码,pendingApproval 缺失即 undefined |
v1→v2 无损(新字段可选) |
|
2 |
按当前布局解码,pendingPlanReview/planReviewApproved 缺失即默认 |
v2→v3 无损(新字段可选) |
|
3 |
直接解码 |
当前版本 |
|
>3(未来版本) |
硬拒绝,返回 schema_version_unsupported |
降级读取有数据风险 |
|
非法(负数、非整数、非数字) |
返回 schema_version_invalid |
防御性 |
这里的核心设计判断是:minor 版本只追加可选字段(或新枚举的 raw fallback),所以旧版本读起来天然兼容(缺失字段填默认)。但删除字段或改字段语义必须升级 schemaVersion 并提供迁移器。当前 v0→v3 的兼容是"字段只增不改"的结果,没有破坏性变更,所以不需要显式迁移代码。
5.3 v2→v3 的无损迁移示例
看 pendingPlanReview 的解码逻辑,就能理解"缺失即默认"是怎么实现无损迁移的:
// v2 → v3: pendingPlanReview / planReviewApproved absent → defaults.
if (object.has('pendingPlanReview')) {
const prObject = object.get('pendingPlanReview')?.asObject()
if (prObject === undefined) {
return Result.failure<AgentState, ArkAgentError>(ArkAgentError.decode(
'pending_plan_review_type', 'pendingPlanReview must be object'))
}
const pr = AgentStateCodec.decodePendingPlanReview(prObject)
if (!pr.ok) {
return Result.failure<AgentState, ArkAgentError>(pr.error as ArkAgentError)
}
state.pendingPlanReview = pr.value
}
if (object.has('planReviewApproved')) {
const flag = Decode.optionalBoolean(object, 'planReviewApproved')
if (!flag.ok) {
return Result.failure<AgentState, ArkAgentError>(flag.error as ArkAgentError)
}
state.planReviewApproved = flag.value ?? false
} else {
state.planReviewApproved = false // ← v2 数据没有这个字段,默认 false
}
v2 的状态文件里没有 pendingPlanReview 和 planReviewApproved,decode 时 object.has(...) 返回 false,直接走 else 分支填默认值。这就完成了 v2→v3 的迁移,零代码、零风险。前提是 v3 新增的字段都是可选的,且有合理的默认值。
六、⚠️ 踩坑一:状态 reload corrupt(空 contents)
这是阶段 4 报告里记录的最隐蔽的一个坑。
症状
含 tool call 的 history 保存后 load 失败。具体表现:Agent 跑了一轮,模型返回了一个 tool call(assistant 消息带 toolCalls 但 contents 为空),执行完工具,状态保存到文件。进程重启后 load,直接报 message_empty_contents 错误,整个会话不可恢复。
根因
问题出在 DomainCodec.decodeMessage 的校验逻辑。原始逻辑是:
// ❌ 原始逻辑:contents 为空一律拒绝
if (contents.length === 0) {
return Result.failure<AgentMessage, ArkAgentError>(ArkAgentError.decode(
'message_empty_contents', 'Message contents must not be empty'))
}
这条规则本身是合理的——一条消息总该有内容。但它没考虑到一个合法场景:assistant 消息只发起了 tool call,没有任何文本内容。模型有时会这样返回——它不说话,直接调工具。这时 contents 是空数组,toolCalls 非空。这条消息是合法的,encode 时能正常写入(encode 不校验 contents 是否为空),但 decode 时被这条规则一刀切拒绝了。
保存(encode)能过,读取(decode)过不了——于是状态"存得进去,读不出来",表现为 reload corrupt。
修复
修复是给空 contents 开一个口子:有 toolCalls 时允许空 contents。
// ✅ 修复后:有 toolCalls 时允许空 contents
const contents = contentsResult.value as ContentPart[]
const toolCalls = callsResult.value as ToolCall[]
// Assistant tool-call-only messages may have empty contents; otherwise require content.
if (contents.length === 0 && (toolCalls === undefined || toolCalls.length === 0)) {
return Result.failure<AgentMessage, ArkAgentError>(ArkAgentError.decode(
'message_empty_contents', 'Message contents must not be empty'))
}
修复后的语义:一条消息要么有 contents(文本/图片等),要么有 toolCalls(工具调用),两者至少有其一。纯 tool-call 的 assistant 消息现在能正常往返了。
被拒绝的方案
考虑过"encode 时给空 contents 填一个占位文本"——但这会污染历史(多出一条模型没说的话),发给模型时模型会困惑。也考虑过"decode 时 silently 容忍"——但 silent 容忍会掩盖真正的数据损坏(比如一条本该有内容的消息被损坏成空 contents)。最终选择显式开口子:只在有 toolCalls 时允许空 contents,其他情况仍严格拒绝。
这个坑的教训是:encode 和 decode 的校验规则必须对称。encode 允许的状态,decode 必须能读回来。否则就会出现"存得进去读不出来"的死锁。
七、⚠️ 踩坑二:schema_version_unsupported 错误地回退 bak
这是阶段 4 验收第二轮 P1 修正记录的第 3 项,踩坑记录的第 6 项。
症状
main 文件是 v2 版本(schemaVersion=2),当前 App 是 v3。按理 v2 应该能被 v3 读(前文说过 v2→v3 无损迁移)。但如果场景反过来——用户先用 v3(schemaVersion=3)写了 main,然后降级回 v2 的 App,v2 读 v3 的 main 会报 schema_version_unsupported。原始的 load 逻辑会回退读 bak,如果 bak 是 v1/v2,就静默加载了旧快照。
这听起来"还行"?问题在于:用户在 v3 里做的所有操作(比如新增的 pendingPlanReview)都被丢了,但用户毫不知情,以为加载的是最新状态。更糟的是,如果 v3 的 main 是损坏的(不是版本问题,是 JSON 坏了),但碰巧 bak 是旧版本,也会静默回退,掩盖了真正的损坏。
根因
原始 load 逻辑对所有错误一视同仁——只要 main 读失败,就尝试 bak。没有区分"main 损坏"(数据问题,回退 bak 合理)和"main 版本不支持"(版本问题,回退 bak 危险)。
修复
修复是在 load 里加一个特判:schema_version_unsupported 不回退 bak,直接抛出。
// main 读失败
catch (mainError) {
// ⚠️ 关键:未来版本不回退 bak
if (mainError instanceof ArkAgentError &&
mainError.code === 'schema_version_unsupported') {
throw mainError // ← 直接抛,不读 bak
}
// 其他错误(corrupt 等)才考虑读 bak
const bakExists = await this.fs.exists(bak)
// ...
}
bak 读取也加了同样的特判:
try {
return await this.loadFromPath(bak, sessionId)
} catch (bakError) {
// bak 也是不支持的版本,同样硬拒绝(没有进一步回退)
if (bakError instanceof ArkAgentError &&
bakError.code === 'schema_version_unsupported') {
throw bakError
}
throw StorageErrors.corrupt(sessionId, `main and bak corrupt: ...`)
}
修复后的语义:
|
场景 |
修复前 |
修复后 |
|---|---|---|
|
main=v3 损坏,bak=v2 完整 |
load bak(静默回退) |
load bak(合理,数据损坏) |
|
main=v3 版本不支持,bak=v2 |
load bak(静默丢数据) |
抛 schema_version_unsupported |
|
main=v3 版本不支持,bak 不存在 |
抛 corrupt |
抛 schema_version_unsupported |
|
main=v3 版本不支持,bak=v3 版本不支持 |
抛 corrupt |
抛 schema_version_unsupported |
修复的核心判断:版本不支持是"我读不懂",不是"文件坏了"。读不懂时静默回退旧版本,等于偷偷降级用户的数据,比直接报错更危险。报错至少让用户知道"你需要升级 App 才能读这个会话"。
被拒绝的方案
考虑过"版本不支持时弹个对话框问用户要不要降级加载"——但这把复杂决策推给了用户,大多数用户根本不知道版本号是什么意思。也考虑过"自动升级迁移"——但降级场景(v3 数据用 v2 读)根本无法迁移(v2 不认识 v3 的新字段)。最终选择硬拒绝 + 明确错误码,让上层(UI)决定怎么提示用户。
八、Tool Call 配对不变量:持久化边界就是配对边界
状态持久化有一个硬约束:任何未配对的 Tool Call 不得入盘。这个约束由 HistoryInvariants 保证。
8.1 配对规则:每个 toolCall id 必须有匹配的 tool 消息
export class HistoryInvariants {
/**
* Returns true when every assistant toolCall id has a matching tool message.
* Orphan tool results (without a call) also fail.
* When awaitingApproval is true, unpaired trailing assistant tool-calls are allowed
* (frozen batch waiting for user approve/deny).
*/
static toolCallsPaired(messages: AgentMessage[], awaitingApproval: boolean = false): boolean {
const pending = new Set<string>()
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
if (msg.role === MessageRole.assistant && msg.toolCalls.length > 0) {
// assistant 发起 tool call,记录所有 id
for (let j = 0; j < msg.toolCalls.length; j++) {
pending.add(msg.toolCalls[j].id)
}
} else if (msg.role === MessageRole.tool) {
// tool 消息必须有对应的 pending id,否则是孤儿 result
if (msg.toolCallId === undefined || !pending.has(msg.toolCallId)) {
return false
}
pending.delete(msg.toolCallId)
}
}
if (pending.size === 0) {
return true
}
// 只在审批冻结态允许尾部未配对 tool call
return awaitingApproval
}
// ...
}
规则有两条:
|
规则 |
含义 |
失败示例 |
|---|---|---|
|
每个 assistant toolCall id 必须有匹配的 tool 消息 |
防止"调了没结果" |
assistant 调 |
|
每个 tool 消息必须有对应的 pending toolCall id |
防止"孤儿结果" |
历史里有个 tool 消息 toolCallId= |
为什么这俩规则这么重要?因为发给模型的历史必须合法。主流 LLM API 都要求 tool call 和 tool result 严格配对——如果历史里有个"调了没结果"的 tool call,模型会报错或行为异常;如果有个"孤儿结果",模型会困惑"这是哪次调用的结果"。
8.2 awaitingApproval 例外:审批冻结态允许尾部未配对
规则有个例外:awaitingApproval=true 时,尾部未配对的 tool call 是允许的。这对应一个合法场景——危险工具审批冻结。模型发起了危险 tool call,但工具还没执行(等用户批准),这时历史尾部确实有未配对的 tool call。这是有意为之的冻结态,不是数据损坏。
正常态(awaitingApproval=false):
assistant: toolCalls=[call_1] ← pending={call_1}
tool: toolCallId=call_1 ← pending={} ✅ 配对完成
审批冻结态(awaitingApproval=true):
assistant: toolCalls=[call_1] ← pending={call_1}
(等用户批准,tool 消息还没产生) ← pending={call_1},awaitingApproval 允许
损坏态(任何情况都不允许):
assistant: toolCalls=[call_1]
tool: toolCallId=call_2 ← 孤儿 result,pending 还有 call_1,必失败
8.3 truncateToPaired:紧急截断到最近的安全点
如果 suspend(挂起)时无法安全完成一个 tool batch(比如模型流刚返回 tool call,工具还没执行完),truncateToPaired 会从尾部截断,直到历史完全配对:
static truncateToPaired(messages: AgentMessage[]): AgentMessage[] {
const result = messages.slice()
while (result.length > 0) {
if (HistoryInvariants.toolCallsPaired(result)) {
return result
}
result.pop() // ← 从尾部逐条丢弃,直到配对
}
return result
}
这是个"宁丢勿破"的策略——与其持久化一个半截的 tool batch(下次发给模型会报错),不如丢掉这半截,从上一个安全点恢复。
阶段 4 报告的实施心得第一条就是这句话:持久化边界 = 配对边界;任何未配对 Tool 不得入盘。这是整个状态体系的纲领性判断。
九、挂起/恢复机制:isRunning 不等于连接存活
鸿蒙的 Ability 随时可能被切到后台、被系统杀掉。Agent 必须能优雅挂起、之后还能恢复。
9.1 挂起 vs 取消:语义必须区分
ArkAgent 把"挂起"(suspend)和"取消"(cancel)严格区分:
|
操作 |
可恢复性 |
行为 |
|---|---|---|
|
|
✅ 可恢复 |
在安全检查点暂停,保存状态(isRunning=true),下次能 resume |
|
cancel |
❌ 不可恢复 |
立即停止,保存终态(isRunning=false),下次不能 resume |
这两者的关键差异在 isRunning 标志和 pendingRunCheckpoint。suspend 保存 isRunning=true + 填充 checkpoint;cancel 保存 isRunning=false + 清 checkpoint。load 时根据这俩字段判断"能不能 resume"。
9.2 requestSuspend:只在安全检查点挂起
suspend 不是随时能挂的——它必须等到一个安全检查点。什么是安全检查点?就是历史完全配对的时刻。阶段 4 报告的关键决策 2 原话:"暂停不取消 Tool 信号:优先等 batch 配对;模型流才断连接。"
具体来说,suspend 会等到以下任一时刻:
安全检查点(可 suspend):
├── 用户输入后(after_user_input) ← 还没开始跑,天然安全
├── 无 Tool 的完整响应后 ← 模型说完话了,没有 tool call
├── Tool batch 全配对后 ← 所有 tool call 都有 result 了
└── 暂停/取消/错误收尾 ← 已经是终态了
非安全检查点(不可 suspend,需等待或截断):
├── 模型正在流式返回 ← 断连接,但等模型流自然结束
├── assistant 发起 tool call,工具执行中 ← 等 batch 配对,不强行中断
└── tool 部分执行,result 未返回 ← 等 result 返回,或 truncateToPaired
为什么不在 tool 执行中强行 suspend?因为那样会留下未配对的 tool call,下次 load 出来的历史不合法,发给模型会报错。所以宁可等一会儿,等到 batch 配对,再安全挂起。如果实在等不到(比如工具卡死),就用 truncateToPaired 截断到上一个安全点,丢弃半截 batch。
9.3 resume:创建新网络调用,不复活旧连接
resume 的设计判断很重要:恢复时创建新的网络调用,不试图复活旧的 HTTP 连接。阶段 4 报告明确:"isRunning=true 只表示存在可恢复的 run,不表示 HTTP 连接存活。"
这意味着 isRunning 是个"逻辑标志",不是"物理标志":
isRunning = true 的含义:
✅ 这个会话有未完成的 run,历史状态合法,可以继续跑
❌ 不表示原来的 HTTP 连接还在(连接在进程被杀时就断了)
❌ 不表示原来的模型流还在(流在断连接时就结束了)
resume 做什么:
1. load 状态(读到 isRunning=true + checkpoint)
2. 用当前历史构造新的 ModelRequest
3. 发起新的网络调用(全新的 HTTP 连接)
4. 继续跑 Agent Loop
为什么这么设计?因为 HTTP 连接是进程级资源,进程死了连接就没了,"复活旧连接"在技术上不可能。诚实的做法是承认"连接没了,但逻辑状态还在",用新连接续跑。已完成的 Tool 不会重复执行(因为结果已经在历史里),只重新发起模型调用。
9.4 安全保存点的时机
Runtime 会在这些时机自动保存(autoSave):
|
时机 |
检查点名 |
状态 |
说明 |
|---|---|---|---|
|
用户输入后 |
|
isRunning=true |
还没开始跑,但已锁定要跑 |
|
无 Tool 完整响应后 |
|
视情况 |
模型说完话,无 tool call |
|
Tool batch 全配对后 |
|
视情况 |
所有 tool 执行完,result 都在 |
|
暂停收尾 |
|
isRunning=true + checkpoint |
可恢复 |
|
取消收尾 |
|
isRunning=false |
不可恢复 |
|
错误收尾 |
|
isRunning=false + lastError |
不可恢复 |
|
正常完成 |
|
isRunning=false |
不可恢复 |
阶段 4 踩坑 5 记录了一个顺序问题:用户输入检查点与 isRunning 顺序。原始逻辑是先 save 后置 isRunning=true,结果模型中途被杀时,保存的状态里 isRunning 还是 false,无法 resume。修复后是先置 isRunning=true 再 save:
❌ 错误顺序:save(isRunning=false) → isRunning=true → 模型跑 → 被杀
重启 load:isRunning=false,无法 resume(虽然状态其实可恢复)
✅ 正确顺序:isRunning=true → save(isRunning=true) → 模型跑 → 被杀
重启 load:isRunning=true,可以 resume
这个坑的教训:保存的状态必须反映"即将进入的运行态",而不是"当前的静态"。在用户输入后,Agent 马上要开始跑,所以保存时就应该置 isRunning=true,即使模型还没真的开始。
十、终态保存失败的兜底:persistNonResumableTerminal
阶段 4 验收第二轮 P1 的第 5 项是个深层问题:终态保存失败不得留下可恢复的陈旧快照。
10.1 问题场景
Agent 跑到终态(complete/cancel/error),要保存终态快照(isRunning=false)。但这次保存失败了(比如磁盘满)。如果不处理,下次 load 会读到上一个中间快照(isRunning=true),误以为还能 resume——但实际上这个 run 已经逻辑结束了。
10.2 解决方案:markNotResumable
统一的终态保存路径 persistNonResumableTerminal:全量 save 失败时,调用 StateStorage.markNotResumable 打 .ended 标记。下次 load 时,即使读到 isRunning=true 的中间快照,也会被 .ended 标记强制改成 isRunning=false。
Agent 终态(complete/cancel/error)
│
├─ save(isRunning=false) 成功 → 完美,next load 读到终态
│
└─ save 失败
│
├─ markNotResumable(写 .ended 标记)
│ │
│ └─ next load:读到中间快照(isRunning=true) + .ended
│ → 强制 isRunning=false + 清 checkpoint
│ → 诚实承认"不可恢复,但历史可查"
│
└─ markNotResumable 也失败 → saveError 上报,isRunning 行为未定义
(极端情况,依赖上层重试或提示)
一个重要细节:suspend(暂停)保存失败不打 not-resumable 标记。因为 suspend 的检查点 after_user_input 是可恢复的,即使保存失败,用户下次还应该能尝试 resume。只有真正的终态(complete/cancel/error)才打标记。这个区分体现在阶段 4 验收记录里:"暂停保存失败仍不打 not-resumable 标记,以便保留 after_user_input 可恢复性。"
十一、最佳实践清单
11.1 状态对象设计
-
✅ 状态根对象从 v1 起就带
schemaVersion,不要等到字段多了才加 -
✅ 状态对象只装纯数据,运行时句柄(Client/Executor/Hook/listeners/cancel)绝不入盘
-
✅ minor 版本只追加可选字段,删除/改义字段必须升级 schemaVersion + 提供迁移器
-
✅ 提供
snapshot()做只读快照,外部观察者只拿快照不碰原对象 -
❌ 不要把可变运行时对象塞进状态,序列化会失败或泄露密钥
-
❌ 不要省略 schemaVersion 字段,否则跨版本兼容无从谈起
11.2 原子写入
-
✅ 用 tmp→bak→main 三段式替换,保证任何时刻 main 都是完整的
-
✅ 写失败时只清理 tmp,绝不碰 main/bak
-
✅ load 时 main 坏则读 bak,双坏则明确报 corrupt
-
✅ 终态保存失败用 markNotResumable 打小标记,不覆盖最后快照
-
❌ 不要直接覆盖写 main(写一半崩溃就废了)
-
❌ 不要在写失败时删除 main(会丢失最后一个好快照)
11.3 版本与安全
-
✅ 未来版本(schemaVersion > CURRENT)硬拒绝,不回退 bak
-
✅ sessionId 永远当数据不当路径,经过 safeSessionFileName 白名单清洗
-
✅ 日志里的 sessionId 用 redact 只显示前 8 字符
-
❌ 不要静默回退旧版本快照(会偷偷降级用户数据)
-
❌ 不要把 sessionId 直接拼进文件路径(路径遍历攻击)
11.4 配对与挂起
-
✅ 持久化边界 = 配对边界,未配对 Tool Call 不得入盘
-
✅ suspend 只在安全检查点挂起(用户输入后/无 Tool 响应后/batch 配对后)
-
✅ resume 创建新网络调用,不试图复活旧连接
-
✅ isRunning=true 只表示"可恢复",不表示"连接存活"
-
❌ 不要在 tool 执行中途强行 suspend(会留下未配对 tool call)
-
❌ 不要把 suspend 和 cancel 混为一谈(前者可恢复,后者不可)
十二、常见错误对照表
|
错误做法 |
问题 |
正确做法 |
|---|---|---|
|
状态对象里持有 HTTP Client |
序列化泄露 token,重启后句柄无效 |
状态只装纯数据,Client 留在 Runtime |
|
直接 |
写一半崩溃,main 变半个 JSON |
写 tmp → 提升 bak → rename tmp 为 main |
|
写失败时 |
丢失最后一个好快照 |
写失败只清理 tmp,保留 main/bak |
|
|
静默降级用户数据,掩盖版本问题 |
硬拒绝,抛 schema_version_unsupported |
|
sessionId 直接拼路径 |
|
safeSessionFileName 白名单清洗 |
|
contents 为空一律拒绝 decode |
tool-only assistant 消息存得进读不出 |
有 toolCalls 时允许空 contents |
|
tool 执行中途 suspend |
留下未配对 tool call,下次发模型报错 |
等到 batch 配对,或 truncateToPaired |
|
resume 时复用旧 HTTP 连接 |
进程死了连接早断,技术上不可能 |
创建新网络调用,用历史续跑 |
|
终态 save 失败不处理 |
下次读到 isRunning=true 的中间快照,误判可恢复 |
markNotResumable 打 .ended 标记 |
|
encode 允许的状态 decode 拒绝 |
"存得进读不出"死锁 |
encode/decode 校验规则对称 |
|
日志全量打印 sessionId |
泄露用户标识 |
redact 只显示前 8 字符 |
|
用 Preferences 存大 history |
KV 读写放大,原子备份弱 |
用 FileStateStorage 原子文件 |
十三、验证清单(真机 Review)
按功能分组,逐项验证状态持久化体系:
13.1 基本读写
-
新建会话 → save → load,字段全等
-
不存在的 sessionId load,抛 session_not_found
-
delete 后 exists 返回 false
-
loadOrCreate 对新 sessionId 创建空状态
-
loadOrCreate 对已存在 sessionId 返回已有状态
13.2 原子写入与崩溃恢复
-
save 成功后,main 完整、bak 为上一次快照、tmp 不存在
-
模拟写 tmp 失败,main/bak 不受影响,抛 saveFailed
-
模拟 rename 失败,main 可能缺失但 bak 还在,load 回退 bak
-
手动损坏 main(写半个 JSON),load 自动读 bak
-
同时损坏 main 和 bak,load 抛 state_corrupt
13.3 版本迁移
-
v0(无 schemaVersion)状态能被 v3 读,缺失字段填默认
-
v1 状态能被 v3 读,pendingApproval 为 undefined
-
v2 状态能被 v3 读,pendingPlanReview 为 undefined、planReviewApproved=false
-
v4(未来版本)状态被 v3 读,抛 schema_version_unsupported
-
schemaVersion 为负数/非整数/非数字,抛 schema_version_invalid
13.4 schema_version_unsupported 不回退
-
main=v3(未来版本)+ bak=v2,load 抛 schema_version_unsupported(不读 bak)
-
main 损坏 + bak=v3(未来版本),load 抛 schema_version_unsupported
-
main 损坏 + bak=v2(当前支持),load 正常读 bak
13.5 路径遍历防护
-
sessionId="" 抛 invalid_session_id
-
sessionId="../etc" 抛 invalid_session_id
-
sessionId="a/b" 抛 invalid_session_id
-
sessionId="ab" 抛 invalid_session_id
-
sessionId="a\0b" 抛 invalid_session_id
-
sessionId="会话-001" 清洗为 "__-001"
-
合法 sessionId(字母数字/-/_/.)原样保留
13.6 配对不变量
-
assistant toolCall + 匹配 tool 消息,toolCallsPaired=true
-
assistant toolCall 无匹配 tool 消息,toolCallsPaired=false
-
孤儿 tool 消息(无对应 call),toolCallsPaired=false
-
awaitingApproval=true 时尾部未配对 toolCall,toolCallsPaired=true
-
truncateToPaired 从尾部截断到最近安全点
13.7 挂起/恢复
-
requestSuspend 在安全检查点挂起,状态 isRunning=true + checkpoint
-
进程重启后 load,读到 isRunning=true,能 resume
-
resume 创建新网络调用,已完成的 Tool 不重复执行
-
cancel 后状态 isRunning=false,load 后不可 resume
-
终态 save 失败后 .ended 标记存在,load 强制 isRunning=false
-
suspend save 失败不打 .ended(保留可恢复性)
13.8 空 contents 兼容
-
纯 tool-call assistant 消息(contents 空 + toolCalls 非空)能 save + load 往返
-
完全空消息(contents 空 + toolCalls 空)decode 失败
-
正常有 contents 的消息正常往返
十四、构建验证
状态持久化体系的验证依赖 ArkAgent 的测试套件。构建与测试命令:
# 设置 SDK 环境(按实际路径替换)
export DEVECO_SDK_HOME=/path/to/deveco/sdk
export NODE_HOME=/path/to/node
# 编译核心 HAR
hvigorw assembleHar --no-daemon
# 跑单元测试(覆盖 state 全套)
hvigorw test --no-daemon
# 编译 entry HAP
hvigorw assembleHap --no-daemon
阶段 4 报告记录的构建结果(2026-07-12):
离线测试:128 pass / 0 fail / 0 error
含两轮 Codex P1 修正与回归
覆盖:AgentState 全字段往返、默认值、v0→v1、未来版本拒绝、
非法类型、未知字段忽略、Memory/File storage 语义与原子/损坏/穿越、
自动保存、reminder 不污染 history、prompt/tools history 变化检测、
suspend/resume、取消不可恢复、provider 错误分层、重启模拟、删除会话
构建:
assembleHar → BUILD SUCCESSFUL
test → 128 pass / 0 fail / 0 error
assembleHap → entry CompileArkTS + PackageHap SUCCESSFUL
验收修正记录(两轮 P1)的关键修复点,本文均已覆盖:
|
轮次 |
修复项 |
本文对应章节 |
|---|---|---|
|
第一轮 P1-3 |
未来 schema 不回退 .bak |
第七章踩坑二 |
|
第二轮 P1-5 |
终态保存失败调 markNotResumable |
第十章 |
|
踩坑 2 |
状态 reload corrupt(空 contents) |
第六章踩坑一 |
|
踩坑 5 |
用户输入检查点先置 isRunning=true |
第九章第四节 |
|
踩坑 6 |
schema_version_unsupported 不回退 |
第七章踩坑二 |
真机生命周期测试(2026-07-12 人工通过)覆盖:切后台安全暂停、前台不自动联网、进程重启后加载并恢复。
十五、写在最后
状态持久化是 Agent 应用"扛得住崩溃"的底线工程。它的难点不在于"存个 JSON"——那只需要三行代码——而在于在任何崩溃时刻都能保证数据完整、跨版本兼容、安全不被绕过、恢复语义清晰。
ArkAgent 的状态体系做了五个核心判断:第一,状态对象只装纯数据,运行时句柄绝不入盘(铁律);第二,原子写入用 tmp→bak→main 三段式,任何时刻 main 都完整(崩溃恢复);第三,版本从 v1 起步,minor 只追加可选字段,未来版本硬拒绝不回退(版本策略);第四,持久化边界就是配对边界,未配对 Tool Call 不得入盘(合法性保证);第五,suspend/cancel 语义分离,isRunning 只表示可恢复不表示连接存活(诚实恢复)。
这五个判断环环相扣——铁律保证安全,原子写入保证完整,版本策略保证兼容,配对不变量保证合法,恢复语义保证诚实。缺任何一环,状态体系都会在某个崩溃场景下翻车。
核心口诀:
数据不夹运行柄,tmp bak main 三段写。 版本只增不改义,未来拒绝不回退。 配对边界即盘界,挂起只等安全点。 isRunning 不带连接,恢复诚实不假装。
更多推荐


所有评论(0)