Skip to content

Commit e4fc788

Browse files
author
weiesky.wangc
committed
fix(v2-read): finalize crash orphans as completed deltas on cold reads (1.7.16)
- dead-session req-without-done now emits as a completed delta (response:{body:null}) so the client reconstructor accumulates its slice; previously the skipped slice failed every later _totalMessageCount check and blanked the whole chat. Live sessions keep the placeholder (liveness gate: owner pid + journal mtime). - degraded broken-carrier merge: a _reconstructBroken entry that is its session's only carrier merges as a truthful prefix (create branches only, _seqEpoch guard), stamped _partialData; ChatView shows an incomplete-session banner. Same-session merges onto a partial base whole-replace instead of prefix-extending. - parity/reorder test pipelines kept in sync; new adapter orphan + predicate tests.
1 parent 4c2765d commit e4fc788

11 files changed

Lines changed: 462 additions & 12 deletions

history.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## 1.7.16 (unreleased)
4+
5+
- fix(v2-read): **crash orphans finalize as completed deltas on cold reads** — a dead session's req-without-done now emits as a completed delta (`response:{body:null}`) instead of an inProgress placeholder, so the client reconstructor accumulates its message slice; previously the slice was skipped, every later entry failed its `_totalMessageCount` check and the batch slimmer's last-carrier merge blanked the whole chat. Live sessions keep the placeholder (liveness gate: owner pid + journal mtime).
6+
- fix(chat): **degraded merge for broken carriers** — a `_reconstructBroken` entry that is its session's only carrier merges as a truthful prefix (create branches only, `_seqEpoch` three-part guard); the session is stamped `_partialData` and ChatView shows an "incomplete session" banner. Same-session merges onto a partial base whole-replace instead of prefix-extending (no tail duplication); anchor-hit merges clear the flag. Parity/reorder test pipelines kept in sync.
7+
38
## 1.7.15 (2026-08-03)
49

510
- feat(chat): **render tool_result images from Claude Code 2.x session logs** — new `server/lib/v2-transcript-normalizer.js` (CLIENT-SAFE) converts the new top-level-`message` JSONL format into legacy entries (per-session grouping, `/clear` segmentation, `message.id` assistant-row merging, `uuid` dedup), wired into cold/local/live ingest. The existing `extractToolResultImages``ToolResultView` chain then renders base64 images unchanged.

server/lib/v2/adapter.js

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { isMainAgentRequest } from '../interceptor-core.js';
3434
import { readSession } from './replay.js';
3535
import { iterateJsonlLines } from './jsonl-read.js';
3636
import { isDiscardableSession } from './session-select.js';
37+
import { LIVE_SESSION_MTIME_MS } from '../log-management.js';
3738
import { blobPath, isSupportedWireFormat } from './layout.js';
3839
import { SingleFlight } from './singleflight.js';
3940
import { listV2Sessions } from './session-list.js';
@@ -183,6 +184,29 @@ export class SessionSynthesizer {
183184
this._leader = opts.teammateOf || this._meta.leader || null;
184185
this._loadBlob = makeBlobLoader({ blobsDir: join(sessionDir, 'blobs') }, this.sessionId);
185186
this.unsupported = false;
187+
// Liveness gate — KEEP IN SYNC with log-management.js deleteLogFiles
188+
// (same pid(0)-probe + EPERM + LIVE_SESSION_MTIME_MS mtime triple; the
189+
// constant is imported here so only the ordering can drift). Cold reads
190+
// finalize crash orphans as completed deltas ONLY when the session is dead.
191+
// A live writer may still emit an orphan's done later — its completed twin
192+
// would then be replayed by the client's seq guard as stale (_staleReorder)
193+
// and the tail of the chat would vanish (cold-read + live-feed race).
194+
this._sessionLive = false;
195+
try {
196+
const meta = this._meta;
197+
if (meta && typeof meta.pid === 'number' && meta.pid !== process.pid) {
198+
try { process.kill(meta.pid, 0); this._sessionLive = true; } catch (err) {
199+
// EPERM = the pid exists under another user — that IS live.
200+
if (err && err.code === 'EPERM') this._sessionLive = true;
201+
}
202+
}
203+
} catch { /* unreadable meta — fall through to the mtime guard */ }
204+
if (!this._sessionLive) {
205+
try {
206+
const jStat = statSync(join(sessionDir, 'journal.jsonl'));
207+
if (this._now() - jStat.mtimeMs < LIVE_SESSION_MTIME_MS) this._sessionLive = true;
208+
} catch { /* no journal — nothing fresh to protect */ }
209+
}
186210
if (meta && meta.wireFormat != null && !isSupportedWireFormat(meta.wireFormat)) {
187211
this._markUnsupported(meta.wireFormat);
188212
}
@@ -508,9 +532,23 @@ export class SessionSynthesizer {
508532

509533
if (this._dones.has(seq)) {
510534
this._tryComplete(seq);
535+
} else if (this._deferMs === 0 && !this._sessionLive) {
536+
// Cold read of a DEAD session: the orphan is definitively terminal. Emit
537+
// it as a completed delta (no inProgress / requestId — the exact shape
538+
// _complete produces) so the client batch reconstructor accumulates its
539+
// message slice: inProgress rows are skipped by the reconstructor, which
540+
// would otherwise drop the slice and fail every later completed entry's
541+
// _totalMessageCount integrity check — blanking the whole chat.
542+
entry.response = { body: null }; // mirrors _complete's no-response branch
543+
// Push the SAME item reference (phase aside): _complete's dedup splice
544+
// (this._out.indexOf(item)) depends on the queued object identity — a
545+
// spread copy would dodge it and the twin completed frame would double.
546+
item.phase = 'completed';
547+
this._out.push(item);
511548
} else {
512549
// req without done — in-flight or the process died (spec §4). This IS the
513-
// v1 placeholder, so it wears the same flags.
550+
// v1 placeholder, so it wears the same flags. Live reads keep it so the
551+
// incremental reconstructor can render the in-flight turn as it streams.
514552
entry.inProgress = true;
515553
entry.requestId = req.rid || `${seq}`;
516554
this._out.push(item);

src/AppBase.jsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { getDefaultBindingsForLocale as vpDefaultBindingsForLocale } from '../se
2020
import { mergeVoicePackInto } from '../server/lib/approval-modal-prefs';
2121
import { saveEntries, loadEntries, clearEntries, getCacheMeta } from './utils/entryCache';
2222
import { assignMessageTimestamps, applyInPlaceLastMsgReplace, getSessionStableId, resolveDisplaySessions, getLatestSessionByActivity, resolveHydratedPin, runPinHydration, applyBatchEntryTimestamps } from './utils/sessionManager';
23-
import { mergeMainAgentSessions as _mergeMainAgentSessions, isMergeBlockedEntry } from './utils/sessionMerge';
23+
import { mergeMainAgentSessions as _mergeMainAgentSessions, isMergeBlockedEntry, shouldDegradeBrokenMerge } from './utils/sessionMerge';
2424
import { reconstructEntries, createIncrementalReconstructor } from '../server/lib/delta-reconstructor.js';
2525
import { normalizeV2Entries, createV2IncrementalReconstructor, isV2TranscriptLine, isMetadataRow } from '../server/lib/v2-transcript-normalizer.js';
2626
import { createEntrySlimmer, createIncrementalSlimmer, internEntryBigFields } from './utils/entry-slim.js';
@@ -606,8 +606,15 @@ class AppBase extends React.Component {
606606
// this slim → applyBatchEntryTimestamps → merge call order.
607607
applyBatchEntryTimestamps(st, entry);
608608

609-
// session 合并(跳过 _slimmed;批量路径额外跳过 stale/broken/inProgress,见谓词 JSDoc)
610-
if (!entry._slimmed && !isMergeBlockedEntry(entry, { batch: true })) {
609+
// Session merge (skips _slimmed; the batch path additionally skips
610+
// stale/broken/inProgress — see the predicate JSDoc). Degradation
611+
// exception (shouldDegradeBrokenMerge): when a broken carrier is its
612+
// session's only carrier, merge it as a truthful prefix and STAMP
613+
// _partialData (the create branches propagate it, ChatView renders the
614+
// banner, and the same-session defuse branch keys off it) — avoids
615+
// blanking the whole chat (2026-07-26 orphan-slice regression).
616+
if (!entry._slimmed && (shouldDegradeBrokenMerge(entry, st.sessions) || !isMergeBlockedEntry(entry, { batch: true }))) {
617+
if (shouldDegradeBrokenMerge(entry, st.sessions)) entry._partialData = true;
611618
st.sessions = this.mergeMainAgentSessions(st.sessions, entry);
612619
}
613620
}

src/components/chat/ChatView.jsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1772,6 +1772,16 @@ class ChatView extends React.Component {
17721772
);
17731773
}
17741774

1775+
// Degraded (partial-data) session banner — rendered unconditionally
1776+
// inside the forEach: the divider above is skipped for the first visible
1777+
// session, but the banner must render for it too (2026-07-26 orphan-slice
1778+
// regression).
1779+
if (session._partialData) {
1780+
allItems.push(
1781+
<div key={`partial-${si}`} className={styles.partialDataBanner}>{t('ui.partialDataBanner')}</div>
1782+
);
1783+
}
1784+
17751785
// Session-level model fallback: when per-message producer resolution is null (the session's
17761786
// carrier entry is in-flight and filtered out — the "MainAgent" flash on carried-over
17771787
// history), fall back to the session's stamped model. The `_fromSession` marker on the

src/components/chat/ChatView.module.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,3 +439,14 @@
439439
.hideLastResponseDivider .lastResponseDivider {
440440
display: none;
441441
}
442+
443+
/* Degraded partial-session banner: data incomplete (interrupted requests), best-effort recovery applied. */
444+
.partialDataBanner {
445+
font-size: 12px;
446+
color: var(--text-muted);
447+
background: var(--bg-surface);
448+
border: 1px solid var(--border-secondary);
449+
border-radius: 6px;
450+
padding: 6px 12px;
451+
margin: 4px 16px 8px;
452+
}

src/i18n.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3678,6 +3678,26 @@ const i18nData = {
36783678
"tr": "MainAgent konuşma verisi yok",
36793679
"uk": "Немає даних діалогу MainAgent"
36803680
},
3681+
"ui.partialDataBanner": {
3682+
"zh": "此会话数据不完整(部分请求中断),已尽力恢复",
3683+
"en": "This session is incomplete (some requests were interrupted); best-effort recovery applied",
3684+
"zh-TW": "此會話資料不完整(部分請求中斷),已盡力恢復",
3685+
"ko": "이 세션의 데이터가 불완전합니다(일부 요청이 중단됨). 최선을 다해 복구했습니다",
3686+
"ja": "このセッションのデータは不完全です(一部のリクエストが中断)。最善を尽くして復元しました",
3687+
"de": "Diese Sitzung ist unvollständig (einige Anfragen wurden unterbrochen); bestmögliche Wiederherstellung angewendet",
3688+
"es": "Esta sesión está incompleta (algunas solicitudes se interrumpieron); se aplicó la mejor recuperación posible",
3689+
"fr": "Cette session est incomplète (certaines requêtes ont été interrompues) ; récupération du mieux possible",
3690+
"it": "Questa sessione è incompleta (alcune richieste sono state interrotte); recupero del meglio possibile",
3691+
"da": "Denne session er ufuldstændig (nogle anmodninger blev afbrudt); bedst mulig gendannelse anvendt",
3692+
"pl": "Ta sesja jest niekompletna (niektóre żądania zostały przerwane); zastosowano najlepszą możliwą rekonstrukcję",
3693+
"ru": "Эта сессия неполна (часть запросов прервана); применено наилучшее возможное восстановление",
3694+
"ar": "هذه الجلسة غير مكتملة (انقطعت بعض الطلبات)؛ تم تطبيق أفضل استرداد ممكن",
3695+
"no": "Denne økten er ufullstendig (noen forespørsler ble avbrutt); best mulig gjenoppretting brukt",
3696+
"pt-BR": "Esta sessão está incompleta (algumas solicitações foram interrompidas); recuperação da melhor forma possível",
3697+
"th": "เซสชันนี้ไม่สมบูรณ์ (บางคำขอถูกขัดจังหวะ); ใช้การกู้คืนที่ดีที่สุดเท่าที่จะทำได้",
3698+
"tr": "Bu oturum eksik (bazı istekler kesildi); mümkün olan en iyi kurtarma uygulandı",
3699+
"uk": "Ця сесія неповна (деякі запити перервано); застосовано найкраще можливе відновлення"
3700+
},
36813701
"ui.fileExplorer": {
36823702
"zh": "文件浏览器",
36833703
"en": "File Explorer",

src/utils/sessionMerge.js

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ export { messageFingerprint };
1818
* 无 live-port 配置下"提问气泡请求时即显示"依赖这一行为。
1919
* AppBase 的 SSE 与批量两个 merge 入口、以及单测共用此谓词,防三处逻辑漂移。
2020
*
21+
* 批量路径的降级例外见 shouldDegradeBrokenMerge:当 broken 条目恰好是它那个
22+
* session 的唯一载体(slim 只留最后一条 main)时,其 messages 是可信前缀,
23+
* 降级合并比整会话空白更可取(会话打 _partialData 标记,ChatView 显示提示条)。
24+
*
2125
* @param {object} entry
2226
* @param {object} [options]
2327
* @param {boolean} [options.batch=false] - 批量(强刷/历史加载)路径
@@ -30,6 +34,36 @@ export function isMergeBlockedEntry(entry, options = {}) {
3034
return false;
3135
}
3236

37+
/**
38+
* 批量路径降级例外:broken 载体能否以"可信前缀"身份合并进 mainAgentSessions。
39+
*
40+
* 仅在两条安全路径放行(两条都只会走进 mergeMainAgentSessions 的创建分支,
41+
* 不会触发 anchor/rebuild 等可能翻倍的对齐逻辑):
42+
* - prevSessions 为空:会话直接创建,无对齐风险;
43+
* - 双方都有 _seqEpoch 且不同:确定性会话边界,走 epochChanged append 分支
44+
* (三部分守卫防 v1 无 epoch 的条目被误放行)。
45+
*
46+
* `_staleReorder` 明确不降级(流内稍后有权威副本自建会话);inProgress 不降级
47+
* (批量下其 messages 是裸切片,不是可信前缀)。v1 / 无 epoch 会话的 broken
48+
* 载体也刻意不降级(放松守卫会让 broken 条目掉进同会话分支,错位前缀 → 翻倍)。
49+
*
50+
* @param {object} entry
51+
* @param {Array} prevSessions - 当前已合并的 sessions
52+
* @returns {boolean} true = 允许降级合并
53+
*/
54+
export function shouldDegradeBrokenMerge(entry, prevSessions) {
55+
if (!entry || !entry._reconstructBroken) return false;
56+
if (entry._staleReorder || entry.inProgress) return false;
57+
if (!Array.isArray(prevSessions) || prevSessions.length === 0) return true;
58+
const e = entry._seqEpoch;
59+
if (!e) return false; // v1 / epoch-less carriers never degrade
60+
// Reject when ANY already-merged session shares the entry's epoch (a same-
61+
// session broken carrier preceded by a healthy one must not degrade into the
62+
// same-session merge branch). Today batches are single-session per epoch, so
63+
// this is a defensive closure for future non-monotonic epoch streams.
64+
return prevSessions.every((s) => !s._seqEpoch || s._seqEpoch !== e);
65+
}
66+
3367
/**
3468
* 增量合并 mainAgent sessions。
3569
*
@@ -39,6 +73,8 @@ export function isMergeBlockedEntry(entry, options = {}) {
3973
* - 命中且 overlapLen < newLen:push `newMessages[overlapLen..]`,引用稳定。
4074
* - 未命中:newLen<curLen → rebuild(/compact summary);newLen===curLen → 整段 append(Plan Mode 全新片段);
4175
* newLen>curLen → 严格前缀扩展语义(push tail),fp 加固后真正存在重叠的窗口必被 anchor 命中。
76+
* - 降级 partial 基底(lastSession._partialData)且 anchor 未命中:任何长度的全量条目都整段替换
77+
* (partial 前缀有中段缺口、不可信,push tail 会翻倍)。
4278
*
4379
* 顶部守卫(isPostClearCheckpoint / userId / transient filter)维持 1.6.245 行为不变。
4480
*
@@ -68,7 +104,13 @@ export function mergeMainAgentSessions(prevSessions, entry, options = {}) {
68104
const entryModel = getEffectiveModel(entry);
69105

70106
if (prevSessions.length === 0) {
71-
return [{ userId, messages: newMessages, response: newResponse, entryTimestamp, model: entryModel, _seqEpoch: seqEpoch }];
107+
return [{
108+
userId, messages: newMessages, response: newResponse, entryTimestamp, model: entryModel, _seqEpoch: seqEpoch,
109+
// Degraded-broken carrier: truthful prefix with a mid-session hole. Only
110+
// the create branches carry it (a same-session merge onto a partial base
111+
// is defused by the whole-replace below); ChatView renders the banner.
112+
...(entry._partialData === true && { _partialData: true }),
113+
}];
72114
}
73115

74116
const lastSession = prevSessions[prevSessions.length - 1];
@@ -90,7 +132,10 @@ export function mergeMainAgentSessions(prevSessions, entry, options = {}) {
90132
for (let i = 0; i < newMessages.length; i++) {
91133
if (!newMessages[i]._timestamp) newMessages[i]._timestamp = entryTimestamp;
92134
}
93-
return [...prevSessions, { userId, messages: newMessages, response: newResponse, entryTimestamp, model: entryModel, _seqEpoch: seqEpoch }];
135+
return [...prevSessions, {
136+
userId, messages: newMessages, response: newResponse, entryTimestamp, model: entryModel, _seqEpoch: seqEpoch,
137+
...(entry._partialData === true && { _partialData: true }),
138+
}];
94139
}
95140

96141
if (!options.skipTransientFilter && isNewConversation && newMessages.length <= 4 && prevMsgCount > 4) {
@@ -119,6 +164,17 @@ export function mergeMainAgentSessions(prevSessions, entry, options = {}) {
119164
lastSession.messages.push(newMessages[i]);
120165
}
121166
}
167+
// Anchor 对齐成功:新全量条目与 partial 前缀吻合,数据已恢复完整。
168+
delete lastSession._partialData;
169+
} else if (lastSession._partialData) {
170+
// 降级合并的 partial 会话(中段缺口,前缀不可信):anchor 未命中时任何
171+
// 同长/更长的全量条目都是权威真值,整段替换而非前缀扩展——否则会 push 出
172+
// 尾部重复(partial 里已含 416..418,新全量 419 会被 append 一遍)。
173+
for (let i = 0; i < newLen; i++) {
174+
if (!newMessages[i]._timestamp) newMessages[i]._timestamp = entryTimestamp;
175+
}
176+
lastSession.messages = newMessages;
177+
delete lastSession._partialData;
122178
} else if (newLen < curLen) {
123179
// /compact summary 等真重建:替换 messages 引用。
124180
for (let i = 0; i < newLen; i++) {
@@ -175,6 +231,9 @@ export function mergeMainAgentSessions(prevSessions, entry, options = {}) {
175231
if (entryModel) lastSession.model = entryModel; // latest wins; model-less entries keep the stamp
176232
return [...prevSessions];
177233
} else {
178-
return [...prevSessions, { userId, messages: newMessages, response: newResponse, entryTimestamp, model: entryModel, _seqEpoch: seqEpoch }];
234+
return [...prevSessions, {
235+
userId, messages: newMessages, response: newResponse, entryTimestamp, model: entryModel, _seqEpoch: seqEpoch,
236+
...(entry._partialData === true && { _partialData: true }),
237+
}];
179238
}
180239
}

0 commit comments

Comments
 (0)