Skip to content

Commit c7a3da5

Browse files
committed
Fix OpenClaw commit failure semantics
1 parent 1cafcd6 commit c7a3da5

7 files changed

Lines changed: 121 additions & 44 deletions

File tree

docs/en/api/05-sessions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1403,8 +1403,8 @@ Commit a session. Message archiving (Phase 1) completes immediately. Summary gen
14031403
**Notes:**
14041404
- Rapid consecutive commits on the same session are accepted; each request gets its own `task_id`.
14051405
- Empty sessions, or commits where all messages remain inside `keep_recent_count`, complete synchronously with `archived: false`.
1406-
- Background Phase 2 work is serialized by archive order: archive `N+1` waits until archive `N` writes `.done`.
1407-
- If an earlier archive failed and left no `.done`, later commit requests fail with `FAILED_PRECONDITION` until that failure is resolved.
1406+
- Background Phase 2 work is serialized by archive order while the direct predecessor is still pending.
1407+
- If Phase 1 archived messages but Phase 2 later fails, the archive is marked with `.failed.json`. That failed archive is terminal and skippable: it is not returned as a completed archive, it does not provide an overview, and it does not block later commit requests.
14081408
- If committed messages contain durable facts, judgments, preferences, or events that mention `viking://resources/...`, memory extraction preserves the resource as a markdown link and records it in `MEMORY_FIELDS.resource_refs`.
14091409

14101410
**Code Entries:**

docs/zh/api/05-sessions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1374,8 +1374,8 @@ curl -X POST http://localhost:1933/api/v1/sessions/a1b2c3d4/used \
13741374
**注意事项**
13751375
- 同一 session 的多次快速连续 commit 会被接受;每次请求都会拿到独立的 `task_id`
13761376
- 空 session,或所有消息都仍在 `keep_recent_count` 保留窗口内时,会同步完成并返回 `archived: false`
1377-
- 后台 Phase 2 会按 archive 顺序串行推进`archive_N+1` 会等待 `archive_N` 写出 `.done` 后再继续
1378-
- 如果更早的 archive 已失败且没有 `.done`,后续 commit 会直接返回错误,直到该失败被处理
1377+
- 后台 Phase 2 会按 archive 顺序串行推进;只有直接前序 archive 仍为 pending 时,后续 archive 才会等待
1378+
- 如果 Phase 1 已经归档消息但 Phase 2 随后失败,该 archive 会写入 `.failed.json`。这个 failed archive 是终态且可跳过:它不会作为 completed archive 返回,不提供 overview,也不会阻塞后续 commit 请求
13791379
- 如果提交的消息中包含带 `viking://resources/...` 的长期事实、评价、偏好或事件,记忆抽取会把资源保留为 markdown 链接,并写入 `MEMORY_FIELDS.resource_refs`
13801380

13811381
**代码入口**

examples/openclaw-plugin/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ Use it as a complement to auto-capture, not a replacement:
229229
- it calls `commit(wait=true)` and blocks for completion
230230
- when an archive exists, it re-reads `latest_archive_overview`
231231
- it returns updated token estimates, the latest archive id, and summary content
232+
- if archiving succeeds but Phase 2 reaches a failed terminal state, the compact boundary is still reported as established, with a degraded reason and the task details attached
232233
- if the summary is too coarse, the model can call `ov_archive_expand` to reopen a specific archive
233234

234235
So `afterTurn()` is closer to "incremental append plus threshold-triggered async commit", while `compact()` is the explicit "wait for archive and compaction to finish" boundary.

examples/openclaw-plugin/README_CN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ preflight 阶段的 `assemble()` 并不是简单地把旧聊天记录塞回来
257257
- 它调用 `commit(wait=true)`,阻塞等待 commit 完成
258258
- 如果有 archive 生成,会再回读 `latest_archive_overview`
259259
- 返回新的 token 估算、latest archive id 和 summary
260+
- 如果归档已成功但 Phase 2 进入 failed 终态,仍会报告压缩边界已建立,并在 reason 和 details 中暴露降级原因
260261
- 如果摘要不够精确,模型可以再调用 `ov_archive_expand` 读取某个 archive 的原始消息
261262

262263
所以 `afterTurn()` 更像"增量写入 + 条件触发异步提交",而 `compact()` 才是"明确等待压缩与归档完成"的正式边界。

examples/openclaw-plugin/docs/openviking-openclaw-plugin-guide.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ transformContext auto recall 流程:
135135

136136
1. 解析 OpenViking session id。
137137
2. 调用 `commitSession(wait=true, keepRecentCount=0)`,要求服务端归档所有当前消息:`context-engine.ts:1500`
138-
3. 如果 Phase 2 failed/timeout,返回失败原因
138+
3. 如果 Phase 1 已经归档但 Phase 2 进入 failed 终态,仍视为压缩边界已建立,并在 `reason` 中报告抽取降级
139139
4. 如果没有生成 archive,返回 `commit_no_archive`
140140
5. 如果归档成功,再回读 `getSessionContext`,获取最新 `latest_archive_overview` 作为 summary:`context-engine.ts:1605`
141141
6. 返回 tokensBefore/tokensAfter、latest archive id 和 summary。
@@ -171,7 +171,7 @@ transformContext auto recall 流程:
171171
插件把 OpenClaw turn 持续写入 OpenViking session,由服务端维护 `pending_tokens` 与 archive。超过阈值时:
172172

173173
- `afterTurn` 路径:`wait=false`,异步 Phase 2,默认保留最近 10 条消息。
174-
- `compact` 路径:`wait=true`,同步等待 Phase 2,`keepRecentCount=0`,形成明确压缩边界。
174+
- `compact` 路径:`wait=true`,同步等待 Phase 2,`keepRecentCount=0`,形成明确压缩边界。若 Phase 2 failed 且服务端返回 `archived=true`,旧消息已经离开 live session,插件会返回 `commit_archived_phase2_failed`,而不是把下一次空 session compact 误判为可重试修复。若 Phase 2 timeout,archive 仍可能处于 pending 并被 context 读取路径回放原文,因此插件仍返回 `commit_timeout`
175175

176176
`commitKeepRecentCount` 默认 10,`commitTokenThresholdRatio` 默认 0.5(模型上下文窗口的 50%):`config.ts`
177177

examples/openclaw-plugin/services/context-lifecycle-service.ts

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ export async function commitOpenVikingSession({
372372
`openviking: commit Phase 2 failed for session=${sessionId}: ${commitResult.error ?? "unknown"}, ` +
373373
`trace_id=${commitResult.trace_id ?? "none"}`,
374374
);
375-
return false;
375+
return commitResult.archived === true;
376376
}
377377
if (commitResult.status === "timeout") {
378378
logger.warn?.(
@@ -1072,45 +1072,45 @@ export async function compactOpenVikingSession({
10721072
keepRecentCount: 0,
10731073
});
10741074
const memCount = totalExtractedMemories(commitResult.memories_extracted);
1075-
1076-
if (commitResult.status === "failed") {
1077-
logger.warn?.(
1078-
`openviking: compact commit Phase 2 failed for session=${ovSessionId}: ` +
1079-
`${commitResult.error ?? "unknown"}, trace_id=${commitResult.trace_id ?? "none"}`,
1080-
);
1075+
const archived = commitResult.archived === true;
1076+
let compactReason = "commit_completed";
1077+
const failCommit = (reason: string, extra: Record<string, unknown> = {}) => {
10811078
diag("compact_result", ovSessionId, {
10821079
ok: false,
10831080
compacted: false,
1084-
reason: "commit_failed",
1081+
reason,
10851082
status: commitResult.status,
10861083
archived: commitResult.archived ?? false,
10871084
taskId: commitResult.task_id ?? null,
1088-
error: commitResult.error ?? null,
1085+
...extra,
10891086
});
1090-
return compactFailureResult("commit_failed", tokensBefore, { commit: commitResult });
1087+
return compactFailureResult(reason, tokensBefore, { commit: commitResult });
1088+
};
1089+
1090+
if (commitResult.status === "failed") {
1091+
logger.warn?.(
1092+
`openviking: compact commit Phase 2 failed for session=${ovSessionId}: ` +
1093+
`${commitResult.error ?? "unknown"}, trace_id=${commitResult.trace_id ?? "none"}`,
1094+
);
1095+
if (!archived) {
1096+
return failCommit("commit_failed", { error: commitResult.error ?? null });
1097+
}
1098+
compactReason = "commit_archived_phase2_failed";
10911099
}
10921100

10931101
if (commitResult.status === "timeout") {
10941102
logger.warn?.(
10951103
`openviking: compact commit Phase 2 timed out for session=${ovSessionId}, ` +
10961104
`task_id=${commitResult.task_id ?? "none"}, trace_id=${commitResult.trace_id ?? "none"}`,
10971105
);
1098-
diag("compact_result", ovSessionId, {
1099-
ok: false,
1100-
compacted: false,
1101-
reason: "commit_timeout",
1102-
status: commitResult.status,
1103-
archived: commitResult.archived ?? false,
1104-
taskId: commitResult.task_id ?? null,
1105-
});
1106-
return compactFailureResult("commit_timeout", tokensBefore, { commit: commitResult });
1106+
return failCommit("commit_timeout");
11071107
}
11081108

11091109
logger.info(
11101110
`openviking: compact committed session=${ovSessionId}, archived=${commitResult.archived ?? false}, memories=${memCount}, task_id=${commitResult.task_id ?? "none"}, trace_id=${commitResult.trace_id ?? "none"}`,
11111111
);
11121112

1113-
if (!commitResult.archived) {
1113+
if (!archived) {
11141114
logger.info(
11151115
`openviking: compact no archive for session=${ovSessionId}, ` +
11161116
`tokensBefore=${tokensBefore}, tokensAfter=${tokensBefore}`,
@@ -1203,7 +1203,7 @@ export async function compactOpenVikingSession({
12031203
diag("compact_result", ovSessionId, {
12041204
ok: true,
12051205
compacted: true,
1206-
reason: "commit_completed",
1206+
reason: compactReason,
12071207
status: commitResult.status,
12081208
archived: commitResult.archived ?? false,
12091209
taskId: commitResult.task_id ?? null,
@@ -1217,7 +1217,7 @@ export async function compactOpenVikingSession({
12171217
return {
12181218
ok: true,
12191219
compacted: true,
1220-
reason: "commit_completed",
1220+
reason: compactReason,
12211221
result: {
12221222
summary,
12231223
firstKeptEntryId,

examples/openclaw-plugin/tests/ut/context-engine-compact.test.ts

Lines changed: 91 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -73,24 +73,41 @@ describe("context-engine commitOVSession()", () => {
7373
expect(ok).toBe(true);
7474
});
7575

76-
it("returns false on failed commit", async () => {
77-
const { engine } = makeEngine({
78-
status: "failed",
79-
error: "extraction error",
80-
});
81-
82-
const ok = await engine.commitOVSession({ sessionId: "test-session" });
83-
expect(ok).toBe(false);
84-
});
85-
86-
it("returns false on timeout commit", async () => {
87-
const { engine } = makeEngine({
88-
status: "timeout",
89-
task_id: "task-timeout",
90-
});
76+
it.each([
77+
{
78+
name: "failed commit without archive",
79+
commitResult: { status: "failed", error: "extraction error" },
80+
expected: false,
81+
},
82+
{
83+
name: "Phase 1 archived before Phase 2 failed",
84+
commitResult: {
85+
status: "failed",
86+
archived: true,
87+
error: "extraction error",
88+
task_id: "task-failed",
89+
},
90+
expected: true,
91+
},
92+
{
93+
name: "timeout commit without archive",
94+
commitResult: { status: "timeout", task_id: "task-timeout" },
95+
expected: false,
96+
},
97+
{
98+
name: "Phase 1 archived but Phase 2 is still pending after timeout",
99+
commitResult: {
100+
status: "timeout",
101+
archived: true,
102+
task_id: "task-timeout",
103+
},
104+
expected: false,
105+
},
106+
])("returns $expected for $name", async ({ commitResult, expected }) => {
107+
const { engine } = makeEngine(commitResult);
91108

92109
const ok = await engine.commitOVSession({ sessionId: "test-session" });
93-
expect(ok).toBe(false);
110+
expect(ok).toBe(expected);
94111
});
95112

96113
it("returns false when commit throws", async () => {
@@ -268,6 +285,36 @@ describe("context-engine compact()", () => {
268285
);
269286
});
270287

288+
it("returns compacted=true when Phase 1 archived before Phase 2 failed", async () => {
289+
const { engine, logger } = makeEngine({
290+
status: "failed",
291+
archived: true,
292+
archive_uri: "viking://user/default/sessions/s3/history/archive_001",
293+
error: "extraction pipeline error",
294+
task_id: "task-3",
295+
});
296+
297+
const result = await engine.compact({
298+
sessionId: "s3",
299+
sessionFile: "",
300+
});
301+
302+
expect(result.ok).toBe(true);
303+
expect(result.compacted).toBe(true);
304+
expect(result.reason).toBe("commit_archived_phase2_failed");
305+
expect(result.result?.firstKeptEntryId).toBe("archive_001");
306+
expect(result.result?.details).toMatchObject({
307+
commit: {
308+
status: "failed",
309+
archived: true,
310+
error: "extraction pipeline error",
311+
},
312+
});
313+
expect(logger.warn).toHaveBeenCalledWith(
314+
expect.stringContaining("Phase 2 failed"),
315+
);
316+
});
317+
271318
it("returns ok=false when commit status is 'timeout'", async () => {
272319
const { engine, logger } = makeEngine({
273320
status: "timeout",
@@ -287,6 +334,34 @@ describe("context-engine compact()", () => {
287334
);
288335
});
289336

337+
it("returns compacted=false when Phase 1 archived but Phase 2 is still pending after timeout", async () => {
338+
const { engine, logger } = makeEngine({
339+
status: "timeout",
340+
archived: true,
341+
archive_uri: "viking://user/default/sessions/s4/history/archive_001",
342+
task_id: "task-4",
343+
});
344+
345+
const result = await engine.compact({
346+
sessionId: "s4",
347+
sessionFile: "",
348+
});
349+
350+
expect(result.ok).toBe(false);
351+
expect(result.compacted).toBe(false);
352+
expect(result.reason).toBe("commit_timeout");
353+
expect(result.result?.details).toMatchObject({
354+
commit: {
355+
status: "timeout",
356+
archived: true,
357+
archive_uri: "viking://user/default/sessions/s4/history/archive_001",
358+
},
359+
});
360+
expect(logger.warn).toHaveBeenCalledWith(
361+
expect.stringContaining("Phase 2 timed out"),
362+
);
363+
});
364+
290365
it("commit passes wait=true for synchronous extraction", async () => {
291366
const { engine, client } = makeEngine({
292367
status: "completed",

0 commit comments

Comments
 (0)