mingdao-harness 0.6.41 → 0.6.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CONFIG.md +19 -12
- package/package.json +1 -1
- package/src/cli.js +2 -2
- package/src/commands/repl.js +4 -4
- package/src/session-events.js +44 -11
- package/src/session.js +58 -4
- package/src/tasks/worker.js +1 -1
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -96,7 +96,7 @@ MingDao 是一个「模型循环 + 工具 + 权限」内核,纯 Node.js ≥ 18
|
|
|
96
96
|
### 2.4 会话 / 记忆 / 任务(11)
|
|
97
97
|
|
|
98
98
|
- `session.js` —— 会话持久化:`<home>/sessions/<时间戳>-<随机>.jsonl` 每轮追加,`--continue` 载入最近一次,`--resume` 打开选择器 —— 会话文件是消息结构的唯一权威,检查点等旁挂状态另存侧车文件。
|
|
99
|
-
- `session-events.js` —— 会话事件日志(v0.7.0 §1.1 切片 A):`<session>.events.ndjson` 追加
|
|
99
|
+
- `session-events.js` —— 会话事件日志(v0.7.0 §1.1 切片 A / B2-a):`<session>.events.ndjson` 追加 13 类事件 `{seq,time,type,data}`(session/turn/step/tool/message/usage/compact/messages.replace/interrupt/checkpoint)—— **seq 是唯一关联键**:跨进程短临界区锁内「读尾行 last+1」、单次 append 后回读校验(单条事件超过尾窗时回退整文件校验最后一行);读侧跳过 torn 行/未知 type 并返回 `skipped`(可忽略性:旧读者遇到新类型只当它不存在);`replayToMessages()` 是纯函数(`message.append` 提供消息本体、`compact`/`messages.replace` 重置基线、`interrupt` 延迟补 tool 占位),由事件流重放的消息数组必须与原消息数组逐字节等价;事件文件是**旁路**:`loadSession` 不读它,`rewriteSession` 只在会话写盘成功后追加 `messages.replace`(写失败只 warn,不改变重写结果),`cfg.eventLog=false` 或 `MINGDAO_EVENT_LOG=0` 可整体回滚;切片 B1 起新增两个纯函数供传输层使用——`readAfter(eventFile, afterSeq)` 只读游标读取(`seq > after`,返回 `{ok,events,lastSeq,skipped}`,文件不存在按空窗口)与 `continuityOf(seqs)` 连续性自检(`{ok,gaps:[{after,before}]}`,seq 必须从 1 起逐条 +1)——仅 `GET /api/events?file=&after=` 与 `GET /api/session` 的 `lastEventSeq` 读事件文件,SSE 帧/UI 仍不读。
|
|
100
100
|
- `session-index.js` —— 会话检索索引:词表倒排 + 按 mtime/size 的增量同步,分片存 `<home>/sessions-index/<2位sha1前缀>.json`(256 片)—— 未变化直接用缓存词表,>8MB 文件跳过并移除条目;中文按 bigram + 单字、查询串同口径分词后 AND 匹配。
|
|
101
101
|
- `memory.js` —— 长记忆与自主进化:用户记忆 `~/.mingdao/AGENTS.md`(手动 + 会话结束自动提取用户偏好并去重追加)、会话日志 `journal.jsonl`(默认不注入系统提示,`--journal` / WebUI 勾选才取最近 3 条)—— 自动提取用 executor 模型(约几十 token),`autoMemory: false` 可关。
|
|
102
102
|
- `tasks.js` —— 多会话后台任务面板:`mingdao run` 拉起独立 worker 子进程,状态落 `<home>/tasks/<id>.json`,命令族 `run` / `tasks` / `tasks watch` / `tasks kill` —— taskId 用不可枚举随机值;kill 前必须过 PID 归属校验(见 `proc.js`)。
|
package/docs/CONFIG.md
CHANGED
|
@@ -90,21 +90,28 @@ MingDao 先用 executor 模型(路由关闭时为当前模型)把被裁段
|
|
|
90
90
|
|
|
91
91
|
每个会话在消息文件旁多一条 append-only 的事件流 `<session>.events.ndjson`(0600,与
|
|
92
92
|
`<session>.jsonl` 同级):每条 `{seq,time,type,data}`,seq 单调、是 turn/step/tool/message
|
|
93
|
-
之间的唯一关联键。当前
|
|
94
|
-
`step.end` / `tool.start` / `tool.end` / `message.append` / `usage` / `compact` /
|
|
95
|
-
`checkpoint`。
|
|
96
|
-
|
|
97
|
-
- **双写、旁路**:`appendMessages()` 在会话消息落盘**之后**追加 `message.append`;
|
|
93
|
+
之间的唯一关联键。当前 13 类事件:`session.start` / `turn.start` / `turn.end` / `step.start` /
|
|
94
|
+
`step.end` / `tool.start` / `tool.end` / `message.append` / `usage` / `compact` /
|
|
95
|
+
`messages.replace` / `interrupt` / `checkpoint`。
|
|
96
|
+
|
|
97
|
+
- **双写、旁路**:`appendMessages()` 在会话消息落盘**之后**追加 `message.append`;
|
|
98
|
+
`rewriteSession()`(压缩 / `--resume` / `/clear` / CLI 全量重写)在会话文件**写盘成功之后**
|
|
99
|
+
追加 `messages.replace`(`data={messages,reason}`,reason 为 `compact` / `resume` / `rewrite`)——
|
|
100
|
+
语义是「把当前派生窗口整体替换为 data.messages」,append-only、不删旧行。agent 在
|
|
98
101
|
turn/step/工具/用量/压缩/中断边界追加结构事件。事件日志写失败只 warn,**不影响会话写入**,
|
|
99
102
|
也不改变 `loadSession()` / `rewriteSession()` 的返回值。
|
|
100
|
-
- **确定性重放**:`replayToMessages(events)` 由事件流派生消息数组(`compact`
|
|
101
|
-
`interrupt` 补 tool 占位),已对拍单轮 / 工具调用 / 压缩 /
|
|
103
|
+
- **确定性重放**:`replayToMessages(events)` 由事件流派生消息数组(`compact` 与
|
|
104
|
+
`messages.replace` 重置基线、`interrupt` 补 tool 占位),已对拍单轮 / 工具调用 / 压缩 /
|
|
105
|
+
resume / 空会话 / `tool_calls` 等场景与消息数组 deepEqual。
|
|
102
106
|
- **回滚开关**:`"eventLog": false`(默认开)或环境变量 `MINGDAO_EVENT_LOG=0`。关闭后**完全**
|
|
103
|
-
停止事件写入(含 `message.append` 双写),会话文件照常写入。
|
|
104
|
-
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
停止事件写入(含 `message.append` 与 `messages.replace` 双写),会话文件照常写入。
|
|
108
|
+
- **兼容**:新增类型只追加、不改变既有事件形状;读侧对未知 type 一律跳过并计入 `skipped`,
|
|
109
|
+
因此旧版本读到 `messages.replace` 只会当它不存在,旧日志(没有该类型)在新版本照常可读。
|
|
110
|
+
旧会话没有 events 文件时读取返回空流;事件侧车不会出现在会话列表/最近会话中;事件文件不
|
|
111
|
+
参与云同步。SSE 帧与 UI 不受影响。
|
|
112
|
+
- **已知边界**:自动标题重命名会话文件不会搬迁事件侧车;WebUI 的 `onCompact` 目前按默认
|
|
113
|
+
`rewrite` 记 reason(本轮不改 `src/web/**`);本版之前写下的日志没有 `messages.replace`,
|
|
114
|
+
`scripts/event-parity.mjs` 将这类「不可回溯」会话判为 `incomplete`(不计失败)。
|
|
108
115
|
|
|
109
116
|
## 会话级工作空间(P3-4)
|
|
110
117
|
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -508,7 +508,7 @@ async function main() {
|
|
|
508
508
|
sessionRef,
|
|
509
509
|
// 自动压缩后重写会话文件 + 同步落盘游标(经 tuiState 共享槽与 REPL 会话联动)
|
|
510
510
|
onCompact: (/** @type {any} */ msgs) => {
|
|
511
|
-
if (tuiState.session) rewriteSession(tuiState.session.file, msgs);
|
|
511
|
+
if (tuiState.session) rewriteSession(tuiState.session.file, msgs, 'compact');
|
|
512
512
|
tuiState.persisted = msgs.length;
|
|
513
513
|
},
|
|
514
514
|
});
|
|
@@ -547,7 +547,7 @@ async function main() {
|
|
|
547
547
|
mcp: mcpFacade,
|
|
548
548
|
sessionRef: oneShotRef,
|
|
549
549
|
onCompact: (/** @type {any} */ msgs) => {
|
|
550
|
-
rewriteSession(session.file, msgs);
|
|
550
|
+
rewriteSession(session.file, msgs, 'compact');
|
|
551
551
|
oneShotPersisted = msgs.length;
|
|
552
552
|
},
|
|
553
553
|
});
|
package/src/commands/repl.js
CHANGED
|
@@ -238,7 +238,7 @@ export async function runRepl(ctx) {
|
|
|
238
238
|
mcp: mcpFacade,
|
|
239
239
|
sessionRef,
|
|
240
240
|
onCompact: (/** @type {any} */ msgs) => {
|
|
241
|
-
rewriteSession(session.file, msgs);
|
|
241
|
+
rewriteSession(session.file, msgs, 'compact');
|
|
242
242
|
tuiState.persisted = msgs.length;
|
|
243
243
|
},
|
|
244
244
|
});
|
|
@@ -327,7 +327,7 @@ export async function runRepl(ctx) {
|
|
|
327
327
|
provider, permission: permissionNow, io, modelName, workingDir, cfg: agentCfg,
|
|
328
328
|
undoStore: sessionUndoStore, mcp: mcpFacade, sessionRef,
|
|
329
329
|
onCompact: (/** @type {any} */ msgs) => {
|
|
330
|
-
rewriteSession(session.file, msgs);
|
|
330
|
+
rewriteSession(session.file, msgs, 'compact');
|
|
331
331
|
tuiState.persisted = msgs.length;
|
|
332
332
|
},
|
|
333
333
|
});
|
|
@@ -363,7 +363,7 @@ export async function runRepl(ctx) {
|
|
|
363
363
|
provider, permission: permissionNow, io, modelName, workingDir, cfg: agentCfg,
|
|
364
364
|
undoStore: sessionUndoStore, mcp: mcpFacade, sessionRef,
|
|
365
365
|
onCompact: (/** @type {any} */ msgs) => {
|
|
366
|
-
rewriteSession(session.file, msgs);
|
|
366
|
+
rewriteSession(session.file, msgs, 'compact');
|
|
367
367
|
tuiState.persisted = msgs.length;
|
|
368
368
|
},
|
|
369
369
|
});
|
|
@@ -415,7 +415,7 @@ export async function runRepl(ctx) {
|
|
|
415
415
|
// v0.6.3(H-4):手动 /compact 此前用**追加**落盘(再补一条"压缩点"标记),而压缩前的历史
|
|
416
416
|
// 早已在文件里 → 每次 /compact 文件近乎翻倍,`-c/--continue` 恢复后历史重复并立刻再次触发压缩。
|
|
417
417
|
// 自动压缩走的是 rewriteSession(原子重写)——两处写盘口径不一致,这里统一到重写。
|
|
418
|
-
rewriteSession(session.file, messages);
|
|
418
|
+
rewriteSession(session.file, messages, 'compact');
|
|
419
419
|
tuiState.persisted = messages.length;
|
|
420
420
|
// v0.6.3(M-1):压缩把早期消息(含文件正文)换成了摘要,但读取去重缓存仍记着"这个文件你看过"
|
|
421
421
|
// → 模型再读同一文件只会拿到「内容与上次读取一致」的占位串,而正文已不在上下文里。
|
package/src/session-events.js
CHANGED
|
@@ -26,7 +26,14 @@ import fs from 'node:fs';
|
|
|
26
26
|
import path from 'node:path';
|
|
27
27
|
import { appendFilePrivateSync } from './atomic-write.js';
|
|
28
28
|
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* 公开事件类型(v0.7.0 B2-a:12 → 13,新增 `messages.replace`)。
|
|
31
|
+
*
|
|
32
|
+
* **可忽略性是新增类型的前提**:读侧对未知 type 一律跳过并计入 `skipped`(见 `readEvents`),
|
|
33
|
+
* 因此旧版读者遇到 `messages.replace` 只会当它不存在,不会把整份日志读坏;反过来,旧日志
|
|
34
|
+
* (没有该类型)在本版照常可读——语义退化为「只有 compact/message.append」,不改变任何
|
|
35
|
+
* 既有事件形状。新增类型必须同时更新这里与 `test/session-events.js` 的清单。
|
|
36
|
+
*/
|
|
30
37
|
export const EVENT_TYPES = Object.freeze([
|
|
31
38
|
'session.start',
|
|
32
39
|
'turn.start',
|
|
@@ -38,6 +45,7 @@ export const EVENT_TYPES = Object.freeze([
|
|
|
38
45
|
'message.append',
|
|
39
46
|
'usage',
|
|
40
47
|
'compact',
|
|
48
|
+
'messages.replace',
|
|
41
49
|
'interrupt',
|
|
42
50
|
'checkpoint',
|
|
43
51
|
]);
|
|
@@ -45,14 +53,14 @@ export const EVENT_TYPES = Object.freeze([
|
|
|
45
53
|
/**
|
|
46
54
|
* 审批事件(v0.6.40 悬空闭合切片):`approval.asked` / `approval.decided` 由审批轨道写入。
|
|
47
55
|
*
|
|
48
|
-
* 为什么不并进 `EVENT_TYPES
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
56
|
+
* 为什么不并进 `EVENT_TYPES`:那份清单是**对外公开**的事件类型(v0.7.0 B2-a 起 13 类),
|
|
57
|
+
* 条数被回归套件(`test/session-events.js` ①)逐字钉死;本切片不允许改那套断言。但读取与
|
|
58
|
+
* 追加又必须认它们——否则修补器看不见「永远在等审批」的悬空 ask,也没法把合成的 decided
|
|
59
|
+
* 追加进日志。因此:`EVENT_TYPES` 仍是 13 类;内部可读写集合 = 13 类 + 审批 2 类。
|
|
52
60
|
*/
|
|
53
61
|
export const APPROVAL_EVENT_TYPES = Object.freeze(['approval.asked', 'approval.decided']);
|
|
54
62
|
|
|
55
|
-
/** 内部接受的事件类型:公共
|
|
63
|
+
/** 内部接受的事件类型:公共 13 类 + 审批 2 类(变量名保持 `EVENT_TYPE_SET`,既有变异锚点不变)。 */
|
|
56
64
|
const EVENT_TYPE_SET = new Set([...EVENT_TYPES, ...APPROVAL_EVENT_TYPES]);
|
|
57
65
|
|
|
58
66
|
/** 审批/工具的关联键字段(按优先级;没有键时按「最近一条」兜底配对)。 */
|
|
@@ -67,6 +75,18 @@ export const SYNTHETIC_REASON = '进程中断(启动时合成闭合事件)';
|
|
|
67
75
|
/** 回滚开关:`MINGDAO_EVENT_LOG=0`(或 false/off/no)时全局关闭双写。 */
|
|
68
76
|
export const EVENT_LOG_ENV = 'MINGDAO_EVENT_LOG';
|
|
69
77
|
|
|
78
|
+
/**
|
|
79
|
+
* 事件日志**格式版本**(写在 `session.start` 的 `data.format` 里)。
|
|
80
|
+
*
|
|
81
|
+
* 为什么需要:0.6.42 之前 `rewriteSession()` 完全不写事件,旧日志**无法**从事件流恢复历史。
|
|
82
|
+
* 对拍工具需要一个可靠依据来区分"旧日志不可回溯(历史噪声)"与"新日志重放回归(真 bug)"——
|
|
83
|
+
* 靠"条数/前缀"猜是不行的(实测会把内容不一致误判成历史噪声)。这就是 DSH `session-format-v*` 的同一思路。
|
|
84
|
+
*
|
|
85
|
+
* 1 = 0.6.35–0.6.41:只有 appendMessages 双写,rewrite 无事件
|
|
86
|
+
* 2 = 0.6.42 起:rewriteSession 也写 `messages.replace`
|
|
87
|
+
*/
|
|
88
|
+
export const EVENT_LOG_FORMAT = 2;
|
|
89
|
+
|
|
70
90
|
/** 中断时补的 tool 占位文案——与 `agent.js#finishTurn` 的既有文案保持逐字一致。 */
|
|
71
91
|
export const INTERRUPTED_TOOL_CONTENT = '(已中断,未执行)';
|
|
72
92
|
|
|
@@ -327,13 +347,24 @@ export function appendEvent(eventFile, { type = '', data = {}, at = null } = {})
|
|
|
327
347
|
const newSize = tail.size + Buffer.byteLength(prefix + json + '\n', 'utf8');
|
|
328
348
|
// 末尾校验:确认最后一行就是刚写下的事件(锁内不会有第二个写方)。
|
|
329
349
|
const verify = readTail(file);
|
|
330
|
-
const
|
|
331
|
-
|
|
350
|
+
const lastLineOf = (/** @type {string} */ text) => {
|
|
351
|
+
const lines = text.split('\n').filter((l) => l.trim());
|
|
352
|
+
return lines.length ? lines[lines.length - 1] : '';
|
|
353
|
+
};
|
|
332
354
|
let ok = false;
|
|
333
355
|
try {
|
|
334
|
-
const parsed = JSON.parse(
|
|
356
|
+
const parsed = JSON.parse(lastLineOf(verify.text).replace(/\r$/, ''));
|
|
335
357
|
ok = parsed?.seq === seq && parsed?.type === type;
|
|
336
358
|
} catch {}
|
|
359
|
+
// v0.7.0 B2-a:单条事件可能 > TAIL_BYTES(压缩摘要/工具结果/整份 messages.replace)。
|
|
360
|
+
// 尾窗会把最后一行截断,JSON.parse 必然失败——这不是写失败。只在「尾窗确实没覆盖
|
|
361
|
+
// 整个文件」时回退整文件重验最后一行(大事件才走这条路,代价与事件大小同阶)。
|
|
362
|
+
if (!ok && verify.size > TAIL_BYTES) {
|
|
363
|
+
try {
|
|
364
|
+
const parsed = JSON.parse(lastLineOf(fs.readFileSync(file, 'utf8')).replace(/\r$/, ''));
|
|
365
|
+
ok = parsed?.seq === seq && parsed?.type === type;
|
|
366
|
+
} catch {}
|
|
367
|
+
}
|
|
337
368
|
if (!ok) return { ok: false, error: `事件写入后校验失败(seq=${seq})` };
|
|
338
369
|
seqCache.set(file, { seq, size: newSize });
|
|
339
370
|
return { ok: true, error: null, seq };
|
|
@@ -692,7 +723,9 @@ export function missingToolCallIds(messages) {
|
|
|
692
723
|
*
|
|
693
724
|
* 语义(与双写顺序一致):
|
|
694
725
|
* · `message.append`:`data.message` 追加到末尾;
|
|
695
|
-
* · `compact`:`data.messages`
|
|
726
|
+
* · `compact` / `messages.replace`:`data.messages` 是**新基线**,整体替换当前窗口
|
|
727
|
+
* (后续 message.append 按 seq 顺序接在它后面)。两者语义相同:`compact` 是切片 A 的旧
|
|
728
|
+
* 类型,`messages.replace`(v0.7.0 B2-a)覆盖压缩 / rewrite / resume 等所有整份重写。
|
|
696
729
|
* · `interrupt`:`data.toolCallIds` 里的 id 若在最终消息里仍没有 tool 回填,则在对应
|
|
697
730
|
* assistant 之后补一条 `(已中断,未执行)` 占位——**延迟到所有事件处理完再补**,这样
|
|
698
731
|
* 随后到达的 message.append 若已含占位就不会重复,也不会插错位置。
|
|
@@ -708,7 +741,7 @@ export function replayToMessages(events) {
|
|
|
708
741
|
if (ev.type === 'message.append') {
|
|
709
742
|
const m = ev.data?.message;
|
|
710
743
|
if (m && typeof m === 'object' && !Array.isArray(m)) messages.push(cloneJson(m));
|
|
711
|
-
} else if (ev.type === 'compact') {
|
|
744
|
+
} else if (ev.type === 'compact' || ev.type === 'messages.replace') {
|
|
712
745
|
const base = ev.data?.messages;
|
|
713
746
|
if (Array.isArray(base)) {
|
|
714
747
|
messages = base.filter((m) => m && typeof m === 'object' && !Array.isArray(m)).map(cloneJson);
|
package/src/session.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { appendEvent, eventFileFor, eventLogEnabled, isEventLogName, repairEventLog, warnEventFailure } from './session-events.js';
|
|
6
|
+
import { appendEvent, eventFileFor, eventLogEnabled, isEventLogName, repairEventLog, warnEventFailure, EVENT_LOG_FORMAT } from './session-events.js';
|
|
7
7
|
|
|
8
8
|
// —— 冲突备份名(单一事实来源,v0.4.6)——
|
|
9
9
|
// 同步冲突时写入的备份:<会话名>.server-<时间戳>[-<随机后缀>].jsonl(远端版本)与 .remote-(远端拉取版本)。
|
|
@@ -134,7 +134,7 @@ function doubleWriteSessionEvents(file, messages) {
|
|
|
134
134
|
try {
|
|
135
135
|
// 事件流的第一条:只在文件尚不存在时写一次(并发下最多重复一条 session.start,无害)。
|
|
136
136
|
if (!fs.existsSync(eventFile)) {
|
|
137
|
-
const start = appendEvent(eventFile, { type: 'session.start', data: { session: path.basename(String(file)) } });
|
|
137
|
+
const start = appendEvent(eventFile, { type: 'session.start', data: { session: path.basename(String(file)), format: EVENT_LOG_FORMAT } });
|
|
138
138
|
if (!start.ok) {
|
|
139
139
|
warnEventFailure(eventFile, start.error ?? null);
|
|
140
140
|
return;
|
|
@@ -161,11 +161,65 @@ export function appendMessages(file, messages) {
|
|
|
161
161
|
doubleWriteSessionEvents(file, messages); // 旁路:失败只 warn,不影响上面的会话写入
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/** 允许的整份重写原因;认不出的一律按 `rewrite`(事件里的 reason 永远是有限集合)。 */
|
|
165
|
+
const REWRITE_REASONS = new Set(['compact', 'resume', 'rewrite']);
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 归一化 `rewriteSession` 的 reason:`compact` / `resume` / `rewrite`,其余(含缺省)→ `rewrite`。
|
|
169
|
+
* @param {any} value
|
|
170
|
+
* @returns {string}
|
|
171
|
+
*/
|
|
172
|
+
function normalizeRewriteReason(value) {
|
|
173
|
+
const s = String(value ?? '').trim();
|
|
174
|
+
return REWRITE_REASONS.has(s) ? s : 'rewrite';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* v0.7.0 B2-a:整份重写(压缩 / --resume / /clear / CLI 全量重写)的双写。
|
|
179
|
+
*
|
|
180
|
+
* 必须在会话文件**写盘成功之后**调用:`messages.replace` 的语义是「把当前派生窗口整体替换为
|
|
181
|
+
* data.messages」,不是增量。先写事件后写会话,一旦会话写失败就会留下「事件侧车已前进、消息
|
|
182
|
+
* 文件没动」的分叉。事件写入失败只 warn(与 appendMessages 同口径),绝不回滚也不抛出——
|
|
183
|
+
* 会话文件已经落盘,事件的完整性不能反过来决定会话是否成功。
|
|
184
|
+
* @param {any} file
|
|
185
|
+
* @param {any[]} messages
|
|
186
|
+
* @param {any} reason
|
|
187
|
+
*/
|
|
188
|
+
function doubleWriteReplaceEvents(file, messages, reason) {
|
|
189
|
+
if (!eventLogEnabled()) return;
|
|
190
|
+
const eventFile = eventFileFor(file);
|
|
191
|
+
if (!eventFile) return;
|
|
192
|
+
try {
|
|
193
|
+
// 与 appendMessages 一致:事件流的第一条 session.start 只在文件尚不存在时写一次。
|
|
194
|
+
if (!fs.existsSync(eventFile)) {
|
|
195
|
+
const start = appendEvent(eventFile, { type: 'session.start', data: { session: path.basename(String(file)), format: EVENT_LOG_FORMAT } });
|
|
196
|
+
if (!start.ok) {
|
|
197
|
+
warnEventFailure(eventFile, start.error ?? null);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const r = appendEvent(eventFile, {
|
|
202
|
+
type: 'messages.replace',
|
|
203
|
+
data: { messages, reason: normalizeRewriteReason(reason) },
|
|
204
|
+
});
|
|
205
|
+
if (!r.ok) warnEventFailure(eventFile, r.error ?? null);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
warnEventFailure(eventFile, String(/** @type {any} */ (err)?.message ?? err));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
164
211
|
// 整文件原子重写:自动压缩后把会话文件同步为压缩形态(否则每次加载历史都会重新触发压缩)
|
|
165
|
-
/**
|
|
166
|
-
|
|
212
|
+
/**
|
|
213
|
+
* @param {any} file
|
|
214
|
+
* @param {any} messages
|
|
215
|
+
* @param {string} [reason] `compact` / `resume` / `rewrite`(缺省或非法 = `rewrite`)
|
|
216
|
+
*/
|
|
217
|
+
export function rewriteSession(file, messages, reason = 'rewrite') {
|
|
167
218
|
const lines = messages.map((/** @type {any} */ m) => JSON.stringify(m)).join('\n');
|
|
168
219
|
atomicWriteFileSync(file, lines + (lines ? '\n' : ''), { mode: 0o600 }); // 质检 H4:会话文件原子写
|
|
220
|
+
// v0.7.0 B2-a:重写是「整体替换窗口」,必须以 messages.replace 落事件(append-only,不删旧行)。
|
|
221
|
+
// 只有会话文件写成功之后才写;失败只 warn(见 doubleWriteReplaceEvents)。
|
|
222
|
+
doubleWriteReplaceEvents(file, messages, reason);
|
|
169
223
|
}
|
|
170
224
|
|
|
171
225
|
/** 每个会话文件每进程只尝试修补一次;失败也不重试(避免每次读取都整份读事件日志)。 */
|
package/src/tasks/worker.js
CHANGED
|
@@ -107,7 +107,7 @@ export async function runWorkerTask(id, question, { permission, model, offpeak }
|
|
|
107
107
|
mcp: mcpFacade || undefined,
|
|
108
108
|
sessionRef,
|
|
109
109
|
onCompact: (/** @type {any} */ msgs) => {
|
|
110
|
-
rewriteSession(session.file, msgs);
|
|
110
|
+
rewriteSession(session.file, msgs, 'compact');
|
|
111
111
|
persistedCount = msgs.length;
|
|
112
112
|
},
|
|
113
113
|
});
|