feat(runtime): 客户端事件窗口缺口处理——Web 快照对账 + CLI 窗口对齐

- Web stores/task.ts:检测 offset < window_start 发合成事件
  event_window_gap(复用 runtime_queue_sync 解耦模式)
- lifecycle.ts 新增 case:fetchAndDisplayHistory({force:true})
  会话 JSON 权威重读对账(协议 §5.4)+ toast 提示
- i18n:stores.eventWindowGap / eventWindowGapReload(zh/en)
- CLI api.ts TaskPollResult 补 window_start;App.tsx pollTask
  检测后提示用户并对齐窗口续读
- 协议文档 §5.2 标注客户端兑现状态

验证:i18n 栏杆通过;CLI typecheck+build 通过;改动文件
eslint 0 errors;前端 vite build 受沙箱读边界限制未跑
(PostCSS 搜索 /Users/jojo/package.json EPERM,环境问题)
This commit is contained in:
JOJO 2026-09-08 08:59:01 +08:00
parent f43b03484d
commit 0381557fe9
6 changed files with 57 additions and 1 deletions

View File

@ -493,7 +493,14 @@ export default function App() {
const renderState = createEventRenderState();
for (;;) {
const result = await api.pollTask(taskId, offset);
offset = result.next_offset;
// 事件窗口缺口检测(协议 §5.2):中间事件已被裁剪,提示用户并对齐窗口继续
const windowStart = Number(result.window_start ?? 0);
if (offset > 0 && offset < windowStart) {
addItem({ kind: 'system', body: `部分实时输出已被裁剪(窗口起点 ${windowStart}),以最终结果为准` });
offset = windowStart;
} else {
offset = result.next_offset;
}
for (const event of result.events || []) {
if (event.type === 'tool_approval_required') {
const approval = event.data?.approval || event.data;

View File

@ -17,6 +17,8 @@ export type TaskPollResult = {
conversation_id?: string;
events: Array<{ idx: number; type: string; data: any; ts?: number }>;
next_offset: number;
// 事件窗口当前最小 idx协议 §5.2offset 落后于此值说明中间事件已被裁剪
window_start?: number;
};
export class ApiClient {

View File

@ -392,6 +392,21 @@ export const lifecycleMethods = {
if (this._rebuildingFromScratch) break;
this.handleRuntimeQueueSync(eventData);
break;
case 'event_window_gap':
// 事件窗口缺口(协议 §5.2/§5.4):中间事件已被服务端裁剪,
// 以会话 JSON 为权威强制重读历史对账。重建期间历史本就是新加载的,跳过。
if (this._rebuildingFromScratch) break;
debugLog('[TaskPolling] 事件窗口缺口,触发会话快照对账:', eventData);
if ((window as any).__vueApp?.uiPushToast) {
(window as any).__vueApp.uiPushToast({
title: t('stores.eventWindowGap'),
message: t('stores.eventWindowGapReload'),
type: 'warning',
duration: 5000
});
}
this.fetchAndDisplayHistory({ force: true });
break;
default:
debugLog(`[TaskPolling] 未知事件类型: ${eventType}`);

View File

@ -60,6 +60,8 @@ export default {
fetchTaskListFailed: 'Failed to fetch task list',
pollingFluctuation: 'Polling fluctuation',
pollingUnstableRetry: 'Message updates are temporarily unstable, retrying automatically',
eventWindowGap: 'Sync gap detected',
eventWindowGapReload: 'Some live updates were trimmed; resyncing conversation content',
requestFailed: 'Request failed',
fileTreeUnavailableHostMode: 'File tree unavailable in host mode',
unlimited: 'Unlimited',

View File

@ -60,6 +60,8 @@ export default {
fetchTaskListFailed: '获取任务列表失败',
pollingFluctuation: '轮询波动',
pollingUnstableRetry: '消息更新暂时不稳定,正在自动重试',
eventWindowGap: '消息同步缺口',
eventWindowGapReload: '部分实时更新已被裁剪,正在重新同步对话内容',
requestFailed: '请求失败',
fileTreeUnavailableHostMode: '宿主机模式下文件树不可用',
unlimited: '未限制',

View File

@ -258,6 +258,34 @@ export const useTaskStore = defineStore('task', {
let sawTaskCompleteEvent = false;
let sawTaskStoppedEvent = false;
// 事件窗口缺口检测(协议 §5.2):请求 offset 落后于窗口起点,说明中间事件
// 已被裁剪禁止当作“无新事件”静默吞掉——通知上层走会话快照对账§5.4
// 本批返回的事件idx >= window_start是连续段游标照常推进。
const windowStart = Number(data?.window_start ?? 0);
if (fromOffset > 0 && fromOffset < windowStart) {
debugLog('[Task] 事件窗口缺口:', { from: fromOffset, windowStart });
taskPollDiag('task-poll-window-gap', {
requestId,
taskId,
from: fromOffset,
windowStart,
nextOffset: data?.next_offset
});
try {
eventHandler({
type: 'event_window_gap',
data: {
task_id: data?.task_id || this.currentTaskId,
conversation_id: data?.conversation_id || null,
from_offset: fromOffset,
window_start: windowStart
}
});
} catch (err) {
console.warn('[Task] 处理 event_window_gap 失败:', err);
}
}
// 处理新事件
if (data.events && data.events.length > 0) {
debugLog(`[Task] 收到 ${data.events.length} 个新事件`);