cc-viewer 1.6.306 → 1.6.308
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/dist/assets/App-b-xfmA9m.js +2 -0
- package/dist/assets/{MdxEditorPanel-oSs95ieb.js → MdxEditorPanel-CotnkeiB.js} +1 -1
- package/dist/assets/{Mobile-CVLG_J2s.js → Mobile-B3EBr5U5.js} +1 -1
- package/dist/assets/{index-1bh2o4MD.js → index-BD-SSlan.js} +2 -2
- package/dist/assets/seqResourceLoaders-D2-X1l9p.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/interceptor.js +15 -0
- package/server/lib/context-watcher.js +4 -4
- package/server/lib/delta-reconstructor.js +242 -120
- package/server/lib/im-log-watcher.js +94 -0
- package/server/lib/log-management.js +19 -0
- package/server/lib/log-stream.js +6 -2
- package/server/pty-manager.js +51 -4
- package/server/routes/im.js +2 -0
- package/server/scratch-pty-manager.js +11 -3
- package/server/server.js +40 -10
- package/dist/assets/App-Ob0BsD2o.js +0 -2
- package/dist/assets/seqResourceLoaders-CC7nzyEk.js +0 -2
package/dist/index.html
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
|
|
22
22
|
// electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
|
|
23
23
|
</script>
|
|
24
|
-
<script type="module" crossorigin src="/assets/index-
|
|
24
|
+
<script type="module" crossorigin src="/assets/index-BD-SSlan.js"></script>
|
|
25
25
|
<link rel="modulepreload" crossorigin href="/assets/vendor-antd-Bur5ZxWE.js">
|
|
26
26
|
<link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-Si44UqBp.js">
|
|
27
27
|
<link rel="modulepreload" crossorigin href="/assets/vendor-mdxeditor-Cco3AQJS.js">
|
package/package.json
CHANGED
package/server/interceptor.js
CHANGED
|
@@ -259,6 +259,15 @@ let _lastTailFp = ''; // 截至最近一次 startRequest 的末位 mes
|
|
|
259
259
|
let _mainAgentDeltaCount = 0; // mainAgent 请求计数器(用于触发定期 checkpoint)
|
|
260
260
|
const CHECKPOINT_INTERVAL = 10; // 每 N 条 mainAgent 请求写一个 checkpoint
|
|
261
261
|
|
|
262
|
+
// 完成序倒置守卫(KEEP IN SYNC: server/lib/delta-reconstructor.js + docs/WIRE_FORMAT.md §3.7):
|
|
263
|
+
// entry 形态在请求发起时冻结,但 completed entry 按响应完成顺序落盘(AsyncWriteQueue FIFO)。
|
|
264
|
+
// burst 下慢请求的条目会落在快请求之后,文件序 ≠ 请求序,重建器按文件序拼接会翻倍。
|
|
265
|
+
// `_seq` 记录请求发起序(语义序),`_seqEpoch` 标识写进程(重启 / 多进程混写时 seq 不可比,
|
|
266
|
+
// 重建器据 epoch 切换基线而不是误判乱序)。teammate 子进程不参与(其条目不进 mainAgent 重建)。
|
|
267
|
+
let _seqCounter = 0;
|
|
268
|
+
// 时间戳 + 6 位随机尾:随机尾用于区分同毫秒启动的第二写进程(IM worker 场景)
|
|
269
|
+
const _seqEpoch = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
270
|
+
|
|
262
271
|
/**
|
|
263
272
|
* Delta storage: completed 写入成功后更新状态。
|
|
264
273
|
*
|
|
@@ -654,6 +663,12 @@ export function setupInterceptor() {
|
|
|
654
663
|
_deltaOriginalTailFp = messages.length > 0 ? fingerprintMsg(messages[messages.length - 1]) : '';
|
|
655
664
|
_mainAgentDeltaCount++;
|
|
656
665
|
|
|
666
|
+
// 完成序倒置守卫:请求发起序号(必须与下方 Plan C eager 块同处一个同步段,中间不得插 await)
|
|
667
|
+
if (!_isTeammate) {
|
|
668
|
+
requestEntry._seq = ++_seqCounter;
|
|
669
|
+
requestEntry._seqEpoch = _seqEpoch;
|
|
670
|
+
}
|
|
671
|
+
|
|
657
672
|
// 并发竞态修复(详见模块顶部注释 + history.md Unreleased 段 fix(interceptor) 条目):
|
|
658
673
|
// snapshot 上一请求处理时的 count/fp 给 Plan C 用,然后 eager 把模块级状态推到本次值
|
|
659
674
|
// (不等 _commitDeltaState)。BUG 来源:teammate 终止快速串行让 mainAgent 30ms 内连续
|
|
@@ -31,8 +31,8 @@ export function readModelContextSize() {
|
|
|
31
31
|
if (sizeMatch) {
|
|
32
32
|
const num = parseInt(sizeMatch[1], 10);
|
|
33
33
|
contextSize = sizeMatch[2] === 'm' ? num * 1000000 : num * 1000;
|
|
34
|
-
} else if (/opus|mythons/i.test(lower)) {
|
|
35
|
-
// Opus / mythons models default to 1M context
|
|
34
|
+
} else if (/opus|mythons|fable[ -]5/i.test(lower)) {
|
|
35
|
+
// Opus / mythons / fable-5 family models default to 1M context
|
|
36
36
|
contextSize = 1000000;
|
|
37
37
|
}
|
|
38
38
|
// Cache the base name → size mapping
|
|
@@ -61,8 +61,8 @@ export function getContextSizeForModel(apiModelName) {
|
|
|
61
61
|
if (_startupModelBase && base === _startupModelBase) {
|
|
62
62
|
return _startupContextSize;
|
|
63
63
|
}
|
|
64
|
-
// Opus / mythons always have 1M context; other unknown models default to 200K
|
|
65
|
-
if (/opus|mythons/i.test(lower)) return 1000000;
|
|
64
|
+
// Opus / mythons / fable-5 family always have 1M context; other unknown models default to 200K
|
|
65
|
+
if (/opus|mythons|fable[ -]5/i.test(lower)) return 1000000;
|
|
66
66
|
return 200000;
|
|
67
67
|
}
|
|
68
68
|
|
|
@@ -2,10 +2,19 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Delta Reconstructor — 增量日志重建模块
|
|
4
4
|
*
|
|
5
|
-
* Wire format 协议详见 docs/WIRE_FORMAT.md(mainAgent entry 形态判定 /
|
|
5
|
+
* Wire format 协议详见 docs/WIRE_FORMAT.md(mainAgent entry 形态判定 / 字段词典 / §3.7 完成序倒置)
|
|
6
6
|
*
|
|
7
7
|
* 将 delta 格式的日志条目重建为完整的 messages 数组。
|
|
8
|
-
* 仅处理 mainAgent
|
|
8
|
+
* 仅处理 mainAgent 条目;teammate(`entry.teammate` 字段,可能与 mainAgent:true 双标并存)
|
|
9
|
+
* 与旧格式条目直接跳过——teammate 子进程与 leader 共写同一日志文件,其消息绝不能进入
|
|
10
|
+
* mainAgent 的累积状态。
|
|
11
|
+
*
|
|
12
|
+
* 完成序倒置守卫(KEEP IN SYNC: server/interceptor.js `_seq` 写入点):
|
|
13
|
+
* entry 形态在请求发起时冻结,但按响应完成顺序落盘,burst 下文件序可能 ≠ 请求序。
|
|
14
|
+
* 重建时跟踪 `_seq`/`_seqEpoch`:同 epoch 内 seq 小于已见最大值的条目判为乱序(stale),
|
|
15
|
+
* 不进累积态并标记 `_staleReorder`(客户端 merge 入口据此跳过);epoch 变化(进程重启 /
|
|
16
|
+
* 换写进程)则重置基线。重建结果与 `_totalMessageCount` 不符时做完整性修复 / 标记
|
|
17
|
+
* `_reconstructBroken`,防脏条目把整段对话二次拼接(mainAgent 整段重复 bug 根因)。
|
|
9
18
|
*
|
|
10
19
|
* 提供三种 API:
|
|
11
20
|
* - reconstructEntries(entries): 批量重建,用于 readLogFile() 和 readLocalLog()
|
|
@@ -32,10 +41,167 @@ export function isCheckpointEntry(entry) {
|
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
/**
|
|
35
|
-
* 判断一个条目是否为需要重建的 delta 条目(mainAgent + _deltaFormat)。
|
|
44
|
+
* 判断一个条目是否为需要重建的 delta 条目(mainAgent + _deltaFormat,排除 teammate)。
|
|
45
|
+
* teammate 子进程的条目可能带 mainAgent:true 双标(system 含 "You are Claude Code"),
|
|
46
|
+
* 必须显式排除,否则其 delta/checkpoint 会污染 leader 的累积状态。
|
|
36
47
|
*/
|
|
37
48
|
export function isDeltaEntry(entry) {
|
|
38
|
-
return entry._deltaFormat && entry.mainAgent;
|
|
49
|
+
return entry._deltaFormat && entry.mainAgent && !entry.teammate;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 判断条目是否参与 mainAgent 累积状态(非 delta 的旧格式全量条目分支共用)。
|
|
54
|
+
*/
|
|
55
|
+
function _isMainAgentFullEntry(entry) {
|
|
56
|
+
return entry.mainAgent && !entry.teammate && Array.isArray(entry.body?.messages);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// 完成序倒置守卫(_seq/_seqEpoch)
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* seq 守卫状态机。st = { lastSeq: 0, lastEpoch: null }。
|
|
65
|
+
* 返回:
|
|
66
|
+
* - 'no-seq':旧日志无 _seq,跳过检查(行为不变)
|
|
67
|
+
* - 'stale' :同 epoch 且 seq 小于已见最大值 → 乱序条目
|
|
68
|
+
* - 'replay':同 epoch 同 seq → 同条重发(日志轮转 race 等)
|
|
69
|
+
* - 'ok' :按序条目(含 epoch 切换),st 已推进
|
|
70
|
+
*/
|
|
71
|
+
function _seqGuardCheck(entry, st) {
|
|
72
|
+
const seq = entry._seq;
|
|
73
|
+
if (typeof seq !== 'number') return 'no-seq';
|
|
74
|
+
const epoch = entry._seqEpoch || null;
|
|
75
|
+
if (st.lastEpoch !== null && epoch === st.lastEpoch) {
|
|
76
|
+
if (seq < st.lastSeq) return 'stale';
|
|
77
|
+
if (seq === st.lastSeq) return 'replay';
|
|
78
|
+
}
|
|
79
|
+
// 按序 / epoch 切换(进程重启 seq 归零、IM worker 等第二写进程):推进基线。
|
|
80
|
+
// 注意 epoch 是盲信的——任何未见过的 epoch 都视同"进程重启"重置基线,没有
|
|
81
|
+
// "哪个 epoch 拥有 mainAgent 流"的概念。双写进程 interleave 时基线会被来回
|
|
82
|
+
// rebase(短暂错误内容),由下一 checkpoint 自愈(≤CHECKPOINT_INTERVAL 条)。
|
|
83
|
+
st.lastEpoch = epoch;
|
|
84
|
+
st.lastSeq = seq;
|
|
85
|
+
return 'ok';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 标记乱序条目并尽量就地补偿:accumulated 已包含更新的真值时,用其前缀
|
|
90
|
+
* (= 截至该条目声称长度的最新内容)回填 body.messages,避免裸 delta 切片
|
|
91
|
+
* 残留在请求详情面板 / mergeLogFiles 落盘产物中。
|
|
92
|
+
* 返回 true 表示已补偿(messages 为一致全量),false 表示需后续 checkpoint 补偿。
|
|
93
|
+
*/
|
|
94
|
+
function _markStaleEntry(entry, accumulated) {
|
|
95
|
+
entry._staleReorder = true;
|
|
96
|
+
const total = entry._totalMessageCount;
|
|
97
|
+
if (Array.isArray(entry.body?.messages) && total && accumulated.length >= total) {
|
|
98
|
+
entry.body.messages = accumulated.slice(0, total);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* delta 拼接后的完整性校验(重建长度 vs `_totalMessageCount`)。
|
|
106
|
+
* - accumulated 超长(典型:旧日志无 _seq 时的倒置——checkpoint 先落、stale delta 后落):
|
|
107
|
+
* slice 回 _totalMessageCount,并把 `st.poisoned` 置位。会话单调增长时该前缀 = 最新
|
|
108
|
+
* checkpoint 真值;但**缩短型 checkpoint(/compact、/clear)跨倒置时前缀是旧会话内容**
|
|
109
|
+
* (局部不可判定),因此基底必须视为不可信:调用方对后续 delta 一律标 _reconstructBroken
|
|
110
|
+
* 冻结,直到下一 checkpoint 重置——否则毒化基底上长度全部自洽,永不自愈。
|
|
111
|
+
* 本条目同时标 _staleReorder 让 merge 跳过。
|
|
112
|
+
* - accumulated 不足(typ.: 倒置中先落盘的快 delta):仅在基线已建立时标 _reconstructBroken
|
|
113
|
+
* ——server 重启 / 客户端 reconstructor 重建后的冷启动 delta 流没有基线,维持现状透传,
|
|
114
|
+
* 否则会把正常增量全部误标导致视图冻结。
|
|
115
|
+
* @param {{baselineSeen: boolean, poisoned: boolean}} st - 重建器完整性状态(原地更新)
|
|
116
|
+
* 返回(可能被 slice 修复过的)accumulated。
|
|
117
|
+
*/
|
|
118
|
+
function _integrityCheck(entry, accumulated, st) {
|
|
119
|
+
const total = entry._totalMessageCount;
|
|
120
|
+
if (!total) return accumulated;
|
|
121
|
+
if (accumulated.length > total) {
|
|
122
|
+
accumulated = accumulated.slice(0, total);
|
|
123
|
+
entry.body.messages = accumulated;
|
|
124
|
+
entry._staleReorder = true;
|
|
125
|
+
st.poisoned = true;
|
|
126
|
+
} else if (accumulated.length < total && st.baselineSeen) {
|
|
127
|
+
entry._reconstructBroken = true;
|
|
128
|
+
}
|
|
129
|
+
return accumulated;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 单条条目的重建状态机核心 — 三个 API(批量/段级/增量)共用。
|
|
134
|
+
* KEEP IN SYNC: server/interceptor.js `_seq` 写入点。
|
|
135
|
+
*
|
|
136
|
+
* 调用方约定:inProgress 条目必须在调用前自行处理(批量/段级跳过、增量重建副本不进
|
|
137
|
+
* 累积态)——placeholder 与 completed 共享同一 _seq,先于守卫排除防 completed 被
|
|
138
|
+
* "同 seq 重发"规则误吞。
|
|
139
|
+
*
|
|
140
|
+
* 经过本函数后 entry.body.messages 的三种终态:
|
|
141
|
+
* 1. 全量重建 —— 正常 delta 拼接 / checkpoint 原样 / replay 幂等回写;
|
|
142
|
+
* 2. 真值前缀 —— stale 就地补偿、超长 slice 修复(_staleReorder 置位,merge 跳过);
|
|
143
|
+
* 3. 裸 delta 切片待补偿 —— stale 就地补偿失败 / poisoned 冻结(_staleReorder 或
|
|
144
|
+
* _reconstructBroken 置位):由第二遍 checkpoint 补偿回填,无第二遍的路径靠
|
|
145
|
+
* isMergeBlockedEntry 阻断 merge、mergeLogFiles 丢弃兜底。
|
|
146
|
+
*
|
|
147
|
+
* @param {object} entry - 单条日志条目(原地修改)
|
|
148
|
+
* @param {{accumulated: Array, intState: {baselineSeen: boolean, poisoned: boolean},
|
|
149
|
+
* seqState: {lastSeq: number, lastEpoch: string|null}}} ctx
|
|
150
|
+
* 重建上下文;ctx.accumulated 由本函数重新赋值(调用方读 ctx 而非局部变量)。
|
|
151
|
+
* @returns {boolean} true = 该条目断裂、需后续 checkpoint 第二遍补偿
|
|
152
|
+
* (批量/段级路径据此收集索引;增量路径无第二遍、忽略返回值)
|
|
153
|
+
*/
|
|
154
|
+
function _stepReconstruct(entry, ctx) {
|
|
155
|
+
if (!isDeltaEntry(entry)) {
|
|
156
|
+
// 非 delta 条目(旧格式 / teammate):如果是 mainAgent 旧格式,重置累积状态
|
|
157
|
+
if (_isMainAgentFullEntry(entry)) {
|
|
158
|
+
ctx.accumulated = [...entry.body.messages];
|
|
159
|
+
ctx.intState.baselineSeen = true;
|
|
160
|
+
ctx.intState.poisoned = false;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const msgs = entry.body?.messages;
|
|
166
|
+
if (!Array.isArray(msgs)) return false;
|
|
167
|
+
|
|
168
|
+
// 完成序倒置守卫
|
|
169
|
+
const verdict = _seqGuardCheck(entry, ctx.seqState);
|
|
170
|
+
if (verdict === 'replay') {
|
|
171
|
+
// 同条重发(日志轮转 race 等):幂等回写全量(不重复累积)
|
|
172
|
+
entry.body.messages = ctx.accumulated;
|
|
173
|
+
if (entry._totalMessageCount && ctx.accumulated.length !== entry._totalMessageCount) {
|
|
174
|
+
entry._staleReorder = true;
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
if (verdict === 'stale') {
|
|
180
|
+
// 就地补偿失败 → 交给后续 checkpoint 反向修复
|
|
181
|
+
return !_markStaleEntry(entry, ctx.accumulated);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (isCheckpointEntry(entry)) {
|
|
185
|
+
// checkpoint:用当前 messages 重置累积状态(毒化态随之解除)
|
|
186
|
+
ctx.accumulated = [...msgs];
|
|
187
|
+
ctx.intState.baselineSeen = true;
|
|
188
|
+
ctx.intState.poisoned = false;
|
|
189
|
+
} else if (ctx.intState.poisoned) {
|
|
190
|
+
// 基底不可信(缩短型 checkpoint 跨倒置被 slice 修复过):冻结到下一 checkpoint,
|
|
191
|
+
// 交给补偿用后续 checkpoint 回填真值
|
|
192
|
+
entry._reconstructBroken = true;
|
|
193
|
+
return true;
|
|
194
|
+
} else {
|
|
195
|
+
// delta:拼接到累积数组,挂载重建后的完整 messages
|
|
196
|
+
ctx.accumulated = [...ctx.accumulated, ...msgs];
|
|
197
|
+
entry.body.messages = ctx.accumulated;
|
|
198
|
+
ctx.accumulated = _integrityCheck(entry, ctx.accumulated, ctx.intState);
|
|
199
|
+
if (entry._staleReorder || entry._reconstructBroken ||
|
|
200
|
+
(entry._totalMessageCount && ctx.accumulated.length !== entry._totalMessageCount)) {
|
|
201
|
+
return true; // 含 slice 修复条目:后续 checkpoint 存在时回填为真值前缀
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return false;
|
|
39
205
|
}
|
|
40
206
|
|
|
41
207
|
/**
|
|
@@ -48,39 +214,19 @@ export function isDeltaEntry(entry) {
|
|
|
48
214
|
*/
|
|
49
215
|
export function reconstructEntries(entries) {
|
|
50
216
|
// 第一遍:正向重建
|
|
51
|
-
|
|
52
|
-
|
|
217
|
+
const ctx = {
|
|
218
|
+
accumulated: [],
|
|
219
|
+
intState: { baselineSeen: false, poisoned: false },
|
|
220
|
+
seqState: { lastSeq: 0, lastEpoch: null },
|
|
221
|
+
};
|
|
222
|
+
const broken = []; // 记录重建失败的条目索引(用于第二遍补偿)
|
|
53
223
|
|
|
54
224
|
for (let i = 0; i < entries.length; i++) {
|
|
55
225
|
const entry = entries[i];
|
|
56
226
|
// 跳过 inProgress 条目:孤立的 inProgress(请求超时未完成)在 dedup 后残留,
|
|
57
227
|
// 其 delta 与后续 completed 条目重复,双重累积会导致 accumulated 偏移
|
|
58
|
-
// (与 createIncrementalReconstructor line 209 保持一致)
|
|
59
228
|
if (entry.inProgress) continue;
|
|
60
|
-
if (
|
|
61
|
-
// 非 delta 条目(旧格式 / teammate):如果是 mainAgent 旧格式,重置累积状态
|
|
62
|
-
if (entry.mainAgent && Array.isArray(entry.body?.messages)) {
|
|
63
|
-
accumulated = [...entry.body.messages];
|
|
64
|
-
}
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// delta 条目处理
|
|
69
|
-
const msgs = entry.body?.messages;
|
|
70
|
-
if (!Array.isArray(msgs)) continue;
|
|
71
|
-
|
|
72
|
-
if (isCheckpointEntry(entry)) {
|
|
73
|
-
// checkpoint:用当前 messages 重置累积状态
|
|
74
|
-
accumulated = [...msgs];
|
|
75
|
-
} else {
|
|
76
|
-
// delta:拼接到累积数组
|
|
77
|
-
accumulated = [...accumulated, ...msgs];
|
|
78
|
-
// 挂载重建后的完整 messages(checkpoint/旧格式条目保持不变)
|
|
79
|
-
entry.body.messages = accumulated;
|
|
80
|
-
if (entry._totalMessageCount && accumulated.length !== entry._totalMessageCount) {
|
|
81
|
-
broken.push(i);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
229
|
+
if (_stepReconstruct(entry, ctx)) broken.push(i);
|
|
84
230
|
}
|
|
85
231
|
|
|
86
232
|
// 第二遍:补偿修复 — 用后续最近的 checkpoint 回填断裂的条目
|
|
@@ -91,32 +237,48 @@ export function reconstructEntries(entries) {
|
|
|
91
237
|
return entries;
|
|
92
238
|
}
|
|
93
239
|
|
|
240
|
+
/**
|
|
241
|
+
* 用候选全量条目(checkpoint/旧格式,排除 teammate)补偿一条断裂条目。
|
|
242
|
+
* 内容已是真值前缀,清除标记让批量 merge / mergeLogFiles 正常消费。
|
|
243
|
+
* @returns {boolean} true = 已补偿
|
|
244
|
+
*/
|
|
245
|
+
function _tryRepairFromCandidate(brokenEntry, expectedCount, candidate) {
|
|
246
|
+
if (!candidate.mainAgent || candidate.teammate || !Array.isArray(candidate.body?.messages)) return false;
|
|
247
|
+
const candidateMsgs = candidate.body.messages;
|
|
248
|
+
const candidateTotal = candidate._totalMessageCount || candidateMsgs.length;
|
|
249
|
+
const isFullEntry = !candidate._deltaFormat || isCheckpointEntry(candidate);
|
|
250
|
+
if (isFullEntry && candidateTotal >= expectedCount) {
|
|
251
|
+
brokenEntry.body.messages = candidateMsgs.slice(0, expectedCount);
|
|
252
|
+
delete brokenEntry._staleReorder;
|
|
253
|
+
delete brokenEntry._reconstructBroken;
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
|
|
94
259
|
/**
|
|
95
260
|
* 补偿修复:对断裂的 delta 条目,从后续最近的 checkpoint 中提取完整 messages 回填。
|
|
96
261
|
* checkpoint 包含截至该点的完整历史,可以据此反推之前条目的 messages。
|
|
262
|
+
* @param {object|null} [nextCheckpoint] - 数组内向后查找失败时的额外候选
|
|
263
|
+
* (段级调用方传入段外的下一 checkpoint,最后一段可为 null)
|
|
97
264
|
*/
|
|
98
|
-
function _compensateBrokenEntries(entries, brokenIndices) {
|
|
265
|
+
function _compensateBrokenEntries(entries, brokenIndices, nextCheckpoint = null) {
|
|
99
266
|
for (const brokenIdx of brokenIndices) {
|
|
100
267
|
const brokenEntry = entries[brokenIdx];
|
|
101
268
|
const expectedCount = brokenEntry._totalMessageCount;
|
|
102
269
|
if (!expectedCount) continue;
|
|
103
270
|
|
|
104
|
-
// 向后查找最近的 checkpoint
|
|
271
|
+
// 向后查找最近的 checkpoint 或旧格式全量条目(排除 teammate)
|
|
272
|
+
let repaired = false;
|
|
105
273
|
for (let j = brokenIdx + 1; j < entries.length; j++) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const candidateMsgs = candidate.body.messages;
|
|
110
|
-
const candidateTotal = candidate._totalMessageCount || candidateMsgs.length;
|
|
111
|
-
|
|
112
|
-
// 候选条目必须是 checkpoint/旧格式且包含足够的 messages
|
|
113
|
-
const isFullEntry = !candidate._deltaFormat || isCheckpointEntry(candidate);
|
|
114
|
-
if (isFullEntry && candidateTotal >= expectedCount) {
|
|
115
|
-
// 从完整 messages 中截取前 expectedCount 条作为补偿
|
|
116
|
-
brokenEntry.body.messages = candidateMsgs.slice(0, expectedCount);
|
|
274
|
+
if (_tryRepairFromCandidate(brokenEntry, expectedCount, entries[j])) {
|
|
275
|
+
repaired = true;
|
|
117
276
|
break;
|
|
118
277
|
}
|
|
119
278
|
}
|
|
279
|
+
if (!repaired && nextCheckpoint) {
|
|
280
|
+
_tryRepairFromCandidate(brokenEntry, expectedCount, nextCheckpoint);
|
|
281
|
+
}
|
|
120
282
|
}
|
|
121
283
|
}
|
|
122
284
|
|
|
@@ -127,66 +289,29 @@ function _compensateBrokenEntries(entries, brokenIndices) {
|
|
|
127
289
|
*
|
|
128
290
|
* @param {Array} segment - 段内条目数组(段首应为 checkpoint/旧格式条目)
|
|
129
291
|
* @param {object|null} nextCheckpoint - 下一个 checkpoint 条目(用于反向修复),最后一段可为 null
|
|
292
|
+
* @param {{lastSeq: number, lastEpoch: string|null}} [sharedSeqState] - 跨段共享的 seq 守卫状态。
|
|
293
|
+
* 流式分段调用方(log-stream)必须按文件维度传入同一对象:乱序的 stale checkpoint 自己就是
|
|
294
|
+
* 段边界,若每段独立建 seqState 它会以 fresh 基线被判 'ok' 漏检。不传时退化为段内独立状态
|
|
295
|
+
* (仅适合单段调用)。
|
|
130
296
|
* @returns {Array} 重建后的段条目数组(原地修改)
|
|
131
297
|
*/
|
|
132
|
-
export function reconstructSegment(segment, nextCheckpoint) {
|
|
133
|
-
|
|
298
|
+
export function reconstructSegment(segment, nextCheckpoint, sharedSeqState) {
|
|
299
|
+
const ctx = {
|
|
300
|
+
accumulated: [],
|
|
301
|
+
intState: { baselineSeen: false, poisoned: false },
|
|
302
|
+
seqState: sharedSeqState || { lastSeq: 0, lastEpoch: null },
|
|
303
|
+
};
|
|
134
304
|
const broken = [];
|
|
135
305
|
|
|
136
306
|
for (let i = 0; i < segment.length; i++) {
|
|
137
307
|
const entry = segment[i];
|
|
138
308
|
if (entry.inProgress) continue;
|
|
139
|
-
if (
|
|
140
|
-
if (entry.mainAgent && Array.isArray(entry.body?.messages)) {
|
|
141
|
-
accumulated = [...entry.body.messages];
|
|
142
|
-
}
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const msgs = entry.body?.messages;
|
|
147
|
-
if (!Array.isArray(msgs)) continue;
|
|
148
|
-
|
|
149
|
-
if (isCheckpointEntry(entry)) {
|
|
150
|
-
accumulated = [...msgs];
|
|
151
|
-
} else {
|
|
152
|
-
accumulated = [...accumulated, ...msgs];
|
|
153
|
-
entry.body.messages = accumulated;
|
|
154
|
-
if (entry._totalMessageCount && accumulated.length !== entry._totalMessageCount) {
|
|
155
|
-
broken.push(i);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
309
|
+
if (_stepReconstruct(entry, ctx)) broken.push(i);
|
|
158
310
|
}
|
|
159
311
|
|
|
160
312
|
// 补偿修复:先在段内向后查找,再用 nextCheckpoint
|
|
161
313
|
if (broken.length > 0) {
|
|
162
|
-
|
|
163
|
-
const brokenEntry = segment[brokenIdx];
|
|
164
|
-
const expectedCount = brokenEntry._totalMessageCount;
|
|
165
|
-
if (!expectedCount) continue;
|
|
166
|
-
|
|
167
|
-
let repaired = false;
|
|
168
|
-
// 段内向后查找
|
|
169
|
-
for (let j = brokenIdx + 1; j < segment.length; j++) {
|
|
170
|
-
const candidate = segment[j];
|
|
171
|
-
if (!candidate.mainAgent || !Array.isArray(candidate.body?.messages)) continue;
|
|
172
|
-
const candidateMsgs = candidate.body.messages;
|
|
173
|
-
const candidateTotal = candidate._totalMessageCount || candidateMsgs.length;
|
|
174
|
-
const isFullEntry = !candidate._deltaFormat || isCheckpointEntry(candidate);
|
|
175
|
-
if (isFullEntry && candidateTotal >= expectedCount) {
|
|
176
|
-
brokenEntry.body.messages = candidateMsgs.slice(0, expectedCount);
|
|
177
|
-
repaired = true;
|
|
178
|
-
break;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
// 段内未找到,用 nextCheckpoint 修复
|
|
182
|
-
if (!repaired && nextCheckpoint) {
|
|
183
|
-
const cpMsgs = nextCheckpoint.body?.messages;
|
|
184
|
-
const cpTotal = nextCheckpoint._totalMessageCount || cpMsgs?.length || 0;
|
|
185
|
-
if (Array.isArray(cpMsgs) && cpTotal >= expectedCount) {
|
|
186
|
-
brokenEntry.body.messages = cpMsgs.slice(0, expectedCount);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
314
|
+
_compensateBrokenEntries(segment, broken, nextCheckpoint);
|
|
190
315
|
}
|
|
191
316
|
|
|
192
317
|
return segment;
|
|
@@ -196,55 +321,49 @@ export function reconstructSegment(segment, nextCheckpoint) {
|
|
|
196
321
|
* 创建有状态的增量重建器 — 用于 watcher 逐条重建。
|
|
197
322
|
* 每次调用 reconstruct(entry) 处理一条新条目。
|
|
198
323
|
*
|
|
199
|
-
* @returns {{ reconstruct: (entry: object) => object }}
|
|
324
|
+
* @returns {{ reconstruct: (entry: object) => object, reset: () => void }}
|
|
200
325
|
*/
|
|
201
326
|
export function createIncrementalReconstructor() {
|
|
202
|
-
|
|
327
|
+
// accumulated:mainAgent 累积 messages;
|
|
328
|
+
// baselineSeen:自创建/reset 后是否见过 checkpoint/旧格式全量条目;
|
|
329
|
+
// poisoned:accumulated 被 slice 修复过(基底不可信),冻结到下一 checkpoint
|
|
330
|
+
const ctx = {
|
|
331
|
+
accumulated: [],
|
|
332
|
+
intState: { baselineSeen: false, poisoned: false },
|
|
333
|
+
seqState: { lastSeq: 0, lastEpoch: null },
|
|
334
|
+
};
|
|
203
335
|
|
|
204
336
|
return {
|
|
205
337
|
/**
|
|
206
338
|
* 重建单条条目。
|
|
207
339
|
* - 非 delta 条目:如果是 mainAgent 旧格式,更新累积状态,原样返回
|
|
340
|
+
* - 乱序条目(_seq 守卫):不进累积态,标 _staleReorder 后返回
|
|
208
341
|
* - checkpoint:重置累积状态,原样返回
|
|
209
|
-
* - delta
|
|
342
|
+
* - delta:拼接重建 + 完整性校验,修改 body.messages 后返回
|
|
210
343
|
*
|
|
211
344
|
* @param {object} entry - 单条日志条目
|
|
212
345
|
* @returns {object} 重建后的条目(同一引用)
|
|
213
346
|
*/
|
|
214
347
|
reconstruct(entry) {
|
|
215
|
-
// inProgress 条目:用 accumulated 副本重建 messages,但不更新 accumulated
|
|
348
|
+
// inProgress 条目:用 accumulated 副本重建 messages,但不更新 accumulated 本身,
|
|
349
|
+
// 也不参与 seq 守卫(placeholder 与 completed 共享同一 _seq,先于守卫跳过,
|
|
350
|
+
// 防 completed 被"同 seq 重发"规则误吞)。
|
|
216
351
|
// 这样客户端收到完整 messages(避免 delta 闪烁),
|
|
217
352
|
// 而后续 completed 条目仍能基于正确的 accumulated 重建。
|
|
218
353
|
if (entry.inProgress) {
|
|
219
354
|
if (isDeltaEntry(entry) && !isCheckpointEntry(entry)) {
|
|
220
355
|
const msgs = entry.body?.messages;
|
|
221
356
|
if (Array.isArray(msgs)) {
|
|
222
|
-
entry.body.messages = [...accumulated, ...msgs];
|
|
357
|
+
entry.body.messages = [...ctx.accumulated, ...msgs];
|
|
223
358
|
}
|
|
224
359
|
}
|
|
225
360
|
return entry;
|
|
226
361
|
}
|
|
227
362
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
return entry;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const msgs = entry.body?.messages;
|
|
237
|
-
if (!Array.isArray(msgs)) return entry;
|
|
238
|
-
|
|
239
|
-
if (isCheckpointEntry(entry)) {
|
|
240
|
-
// checkpoint:重置累积状态
|
|
241
|
-
accumulated = [...msgs];
|
|
242
|
-
} else {
|
|
243
|
-
// delta:拼接
|
|
244
|
-
accumulated = [...accumulated, ...msgs];
|
|
245
|
-
entry.body.messages = accumulated;
|
|
246
|
-
}
|
|
247
|
-
|
|
363
|
+
// 忽略 needs-compensation 返回值:增量路径是实时流、无法回看后续 checkpoint
|
|
364
|
+
// 做第二遍补偿。stale 仅靠 _markStaleEntry 就地回填;未补偿条目带
|
|
365
|
+
// _staleReorder/_reconstructBroken 标记,由客户端 isMergeBlockedEntry 阻断 merge。
|
|
366
|
+
_stepReconstruct(entry, ctx);
|
|
248
367
|
return entry;
|
|
249
368
|
},
|
|
250
369
|
|
|
@@ -252,8 +371,11 @@ export function createIncrementalReconstructor() {
|
|
|
252
371
|
* 重置累积状态(用于 full_reload 等场景)。
|
|
253
372
|
*/
|
|
254
373
|
reset() {
|
|
255
|
-
accumulated = [];
|
|
374
|
+
ctx.accumulated = [];
|
|
375
|
+
ctx.intState.baselineSeen = false;
|
|
376
|
+
ctx.intState.poisoned = false;
|
|
377
|
+
ctx.seqState.lastSeq = 0;
|
|
378
|
+
ctx.seqState.lastEpoch = null;
|
|
256
379
|
}
|
|
257
380
|
};
|
|
258
381
|
}
|
|
259
|
-
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// im-log-watcher.js — 监听 IM worker 日志目录,写入即推 SSE,让「对话记录」弹窗零滞后刷新。
|
|
2
|
+
//
|
|
3
|
+
// 背景:IM worker 是独立进程、独立端口(见 im-process-manager.buildChildEnv,CCV_START_PORT=7050+)。
|
|
4
|
+
// 它的 Claude 子进程 turn 结束时把 turn_end POST 到 worker 自己的端口,主 web 服务收不到。
|
|
5
|
+
// 但 IM worker 的日志写在共享文件系统 ~/.claude/cc-viewer/IM_<id>/*.jsonl,主服务能直接 watch。
|
|
6
|
+
// 于是:主服务 fs.watch 这些目录,助手回复落盘即广播 `im_log_update` SSE,前端据此自动重拉。
|
|
7
|
+
//
|
|
8
|
+
// 设计:
|
|
9
|
+
// - ensure(platformId) 幂等、惰性:「对话记录」弹窗首次请求 /api/im/:platform/logs 时才开始 watch,
|
|
10
|
+
// 避免为从未打开的平台占 watcher(平台数 ≤ 4,成本可忽略)。dir 不存在时先 mkdir 再 watch。
|
|
11
|
+
// - 每个目录的 change 事件按 debounceMs 合并(fs.watch 一次写会抖多次),只在 .jsonl(排除 *_temp.jsonl)
|
|
12
|
+
// 变化时触发——与 findRecentLog 的「最新真实日志」语义对齐,过滤流式临时文件噪声。
|
|
13
|
+
// - watchImpl / mkdirImpl 可注入,便于确定性单测(无需真实 FS 时序)。
|
|
14
|
+
|
|
15
|
+
import { watch as fsWatch, mkdirSync, existsSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
// fs.watch 一次文件写会抖多次(macOS/Linux 尤甚),通常 50~300ms 内合并;选 250ms 平衡实时性与稳定性。
|
|
19
|
+
const DEFAULT_DEBOUNCE_MS = 250;
|
|
20
|
+
// platformId 纵深防御:上游 platformOf() 已用白名单挡住非法平台,这里再独立校验一次,
|
|
21
|
+
// 杜绝被污染的 id 经 `IM_${id}` 拼进路径造成穿越(与路由正则 /^[a-z0-9_-]+$/ 同集)。
|
|
22
|
+
const PLATFORM_ID_RE = /^[a-z0-9_-]+$/;
|
|
23
|
+
|
|
24
|
+
export function createImLogWatcher({ getLogDir, onChange, debounceMs = DEFAULT_DEBOUNCE_MS, watchImpl, mkdirImpl, existsImpl } = {}) {
|
|
25
|
+
const _watch = watchImpl || fsWatch;
|
|
26
|
+
const _mkdir = mkdirImpl || ((d) => { try { mkdirSync(d, { recursive: true }); } catch { /* ignore */ } });
|
|
27
|
+
const _exists = existsImpl || existsSync;
|
|
28
|
+
const _onChange = typeof onChange === 'function' ? onChange : () => {};
|
|
29
|
+
const _getLogDir = typeof getLogDir === 'function' ? getLogDir : () => '';
|
|
30
|
+
|
|
31
|
+
const watchers = new Map(); // platformId -> { w: FSWatcher, dir: string } —— 连同所属目录登记,便于 LOG_DIR 切换检测
|
|
32
|
+
const timers = new Map(); // platformId -> timeout
|
|
33
|
+
let _disposed = false;
|
|
34
|
+
|
|
35
|
+
function _schedule(platformId) {
|
|
36
|
+
const existing = timers.get(platformId);
|
|
37
|
+
if (existing) clearTimeout(existing);
|
|
38
|
+
timers.set(platformId, setTimeout(() => {
|
|
39
|
+
timers.delete(platformId);
|
|
40
|
+
if (_disposed) return;
|
|
41
|
+
try { _onChange(platformId); } catch { /* never propagate to fs.watch */ }
|
|
42
|
+
}, debounceMs));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 只认真实日志的 .jsonl 写入;filename 为 null(部分平台)时保守放行(让前端重拉,pure refresh 无副作用)。
|
|
46
|
+
function _relevant(filename) {
|
|
47
|
+
if (!filename) return true;
|
|
48
|
+
const name = String(filename);
|
|
49
|
+
if (!name.endsWith('.jsonl')) return false;
|
|
50
|
+
if (name.endsWith('_temp.jsonl')) return false;
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ensure(platformId) {
|
|
55
|
+
if (_disposed || !platformId) return;
|
|
56
|
+
if (!PLATFORM_ID_RE.test(platformId)) return; // 纵深防御:非白名单字符不放行(防 `IM_${id}` 路径穿越)
|
|
57
|
+
const logDir = _getLogDir();
|
|
58
|
+
if (!logDir) return;
|
|
59
|
+
const dir = join(logDir, `IM_${platformId}`);
|
|
60
|
+
const reg = watchers.get(platformId);
|
|
61
|
+
if (reg) {
|
|
62
|
+
// 幂等仅当「目录路径未变且仍存在」时成立;否则关旧重建:
|
|
63
|
+
// - reg.dir !== dir:LOG_DIR 运行时切换(切项目),旧 watcher 还盯着旧目录,新目录无人监听;
|
|
64
|
+
// - !exists(dir):目录被删(某些平台 fs.watch 删目录不报 error,留下永不恢复的幽灵监听)。
|
|
65
|
+
if (reg.dir === dir && _exists(dir)) return;
|
|
66
|
+
try { reg.w.close(); } catch { /* ignore */ }
|
|
67
|
+
watchers.delete(platformId);
|
|
68
|
+
}
|
|
69
|
+
_mkdir(dir);
|
|
70
|
+
let w;
|
|
71
|
+
try {
|
|
72
|
+
w = _watch(dir, (_eventType, filename) => {
|
|
73
|
+
if (_relevant(filename)) _schedule(platformId);
|
|
74
|
+
});
|
|
75
|
+
} catch { return; }
|
|
76
|
+
// watcher 出错(目录被删等)→ 关闭并撤销登记,下次 ensure 可重建。
|
|
77
|
+
if (w && typeof w.on === 'function') {
|
|
78
|
+
w.on('error', () => { try { w.close(); } catch { /* ignore */ } watchers.delete(platformId); });
|
|
79
|
+
}
|
|
80
|
+
watchers.set(platformId, { w, dir });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function dispose() {
|
|
84
|
+
_disposed = true;
|
|
85
|
+
for (const t of timers.values()) clearTimeout(t);
|
|
86
|
+
timers.clear();
|
|
87
|
+
for (const reg of watchers.values()) { try { reg.w.close(); } catch { /* ignore */ } }
|
|
88
|
+
watchers.clear();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { ensure, dispose, _watchers: watchers };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export default createImLogWatcher;
|
|
@@ -159,10 +159,29 @@ export async function mergeLogFiles(logDir, files) {
|
|
|
159
159
|
await streamReconstructedEntriesAsync(filePath, async (segment) => {
|
|
160
160
|
let chunk = '';
|
|
161
161
|
for (const entry of segment) {
|
|
162
|
+
// 乱序/断裂条目若未被补偿回填(messages 仍是裸 delta 切片),丢弃不写:
|
|
163
|
+
// 剥除 _deltaFormat 后它会伪装成旧格式全量条目,未来读取时把累积状态
|
|
164
|
+
// 重置成几条切片,比丢掉这条(内容已被更新条目取代)破坏大得多
|
|
165
|
+
if ((entry._staleReorder || entry._reconstructBroken) &&
|
|
166
|
+
entry._totalMessageCount && Array.isArray(entry.body?.messages) &&
|
|
167
|
+
entry.body.messages.length !== entry._totalMessageCount) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
162
170
|
delete entry._deltaFormat;
|
|
163
171
|
delete entry._totalMessageCount;
|
|
164
172
|
delete entry._conversationId;
|
|
165
173
|
delete entry._isCheckpoint;
|
|
174
|
+
// 信号对的另一半同剥:_isCheckpoint 已剥后,孤立的 _inPlaceReplaceDetected
|
|
175
|
+
// 永远无法触发双信号短路(applyInPlaceLastMsgReplace 要求两者同真),留着
|
|
176
|
+
// 只会误导未来读取;_eagerSnapshot 为已废弃字段(写入点已删),仅历史日志残留
|
|
177
|
+
delete entry._inPlaceReplaceDetected;
|
|
178
|
+
delete entry._eagerSnapshot;
|
|
179
|
+
// 完成序倒置守卫的内部字段不落盘:合并产物是已重建的全量条目,
|
|
180
|
+
// seq 序号与 stale/broken 标记只在重建期有意义,泄漏会让后续读取误判
|
|
181
|
+
delete entry._seq;
|
|
182
|
+
delete entry._seqEpoch;
|
|
183
|
+
delete entry._staleReorder;
|
|
184
|
+
delete entry._reconstructBroken;
|
|
166
185
|
chunk += JSON.stringify(entry) + '\n---\n';
|
|
167
186
|
}
|
|
168
187
|
await appendFile(tmpPath, chunk);
|