Merge pull request #2444 from pikasTech/issue-1608-projection-doc-cleanup
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success

fix(workbench): 清理投影一致性与 detail read 口径残留
This commit is contained in:
Lyon
2026-07-09 12:07:08 +08:00
committed by GitHub
15 changed files with 178 additions and 140 deletions
+6 -6
View File
@@ -15,13 +15,13 @@
Workbench 页面和组件必须明确区分“未加载完成”和“已加载但为空”。未加载完成时不得渲染由 workspace、localStorage、默认对象或 stub 记录拼出来的伪数据;这类数据只能作为内部恢复线索,不能在列表、表格、卡片或状态栏中冒充真实加载结果。已经确认后端返回且集合为空时,才显示空态文案。
Workbench 状态对象必须服从 UniDesk OA Web SPEC 中已经定下来的单一权威 API 绑定。Session rail 的会话集合只允许消费 `/v1/agent/conversations` 成功返回的 conversation 集合;当前 selected conversation 如果需要出现在左侧列表中,必须通过显式 query/path/body 传入稳定 conversation id,由该列表 API 在同一响应中返回,后端不得隐藏读取 workspace selected、localStorage、Web snapshot 或上一轮页面状态。前端不得用 workspace selected snapshot、stub、localStorage 或 route 状态补出 session tab,也不得把前端拼接出的 status、final response、markdown、running 动效或 stub 传回后端变成事实。Session 运行状态必须按 `sessionId` 绑定到单一 session 状态 APICode Agent turn、trace 阅读和 final response 必须按 `traceId` 绑定到单一 trace/result snapshot API。任何权威 API 失败时只能保留上一份成功结果或显示未加载/错误态,不得切换到另一条 fallback 路径形成一条会话、旧 running 态或劣化 markdown。
Workbench 状态对象必须服从 UniDesk OA Web SPEC 中已经定下来的单一权威 API 绑定。Session rail 的会话集合只允许消费 `/v1/workbench/sessions` 成功返回的 Workbench session read model;当前 selected conversation 如果需要出现在左侧列表中,必须通过显式 query/path/body 传入稳定 conversation id,由该列表 API 在同一响应中返回,后端不得隐藏读取 workspace selected、localStorage、Web snapshot 或上一轮页面状态。前端不得用 workspace selected snapshot、stub、localStorage 或 route 状态补出 session tab,也不得把前端拼接出的 status、final response、markdown、running 动效或 stub 传回后端变成事实。Session 运行状态turn 投影、trace 终态和 final response 的自动恢复必须以 Workbench read model 和 `/v1/workbench/sync` replay 为权威;`/v1/workbench/turns/:traceId``/v1/workbench/traces/:traceId/events` 与旧 `/v1/agent/*` detail 入口只能服务显式详情、历史查看或兼容调用,不能成为 Workbench 自动投影的第二条事实来源。任何权威 API 失败时只能保留上一份成功结果或显示未加载/错误态,不得切换到另一条 fallback 路径形成一条会话、旧 running 态或劣化 markdown。
Workbench 投影写路径必须做到 0 隐式 fallback。Admission、projection event、terminal/finalizer 等上游写入如果无法把 session/message/turn/checkpoint facts 写入 durable read model,不能 `catch` 后返回空值继续表现为成功;admission 阶段必须显式失败并把错误传给调用方,后台投影阶段必须至少写入 trace diagnostic 和 OTel error span。只有成功落库的 Workbench facts 才能驱动控制页、观察页、session rail、耗时和 final response;前端或 read path 不得用内存 trace、local optimistic state、历史 snapshot 或多来源仲裁去修补失败写入。
Workbench 的 trace/message/projection 运行时必须是独立模块边界。`workbench.ts` 只保留 session/route authority 校验、Pinia state commit、刷新调度和用户动作编排;trace snapshot、terminal result、message timing/status patch、agent error normalize、projection diagnostic 裁剪和 final response 文本提取等纯算法统一由 `web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts` 提供。新增或修复浏览器 smoke 时不得把这些 helper 重新散写回 store 或组件,也不得通过 reload、repair、localStorage truth、GET read-through、测试专用后门或删除 guard 来绕过真实投影问题。web-probe origin、视口、采样、命令超时、provider/lane 和报警阈值只从选中 node/lane 的受控 YAML/source-of-truth 进入验证命令,不在 SPEC 或前端 runtime 中写第二份数值。
Workbench terminal 三态必须作为一个不可拆分的投影不变量维护:session rail 状态、turn 卡片状态和 final response object 要么同时表现为完成且 final response 存在,要么同时表现为运行且 final response 不存在。持久 read model 是 session、messages、turn、trace tuple 的 source of truth;前端 `workbench-server-state` 只能做归一化缓存和单调合并,不能把 stale running 刷新覆盖到同一 trace 的 terminal authority 上。`session.status``turn.status``session.list/detail/messages``message.snapshot`、projection page merge、REST trace hydration 和 SSE trace snapshot 都必须保留已 sealed terminal 的 `status``traceAutoLifecycle``text``finalResponse` 和 terminal timing;新 trace 的 running 可以让同一 session 进入下一轮运行,但同 trace 的 running/non-terminal 只能作为旧事件丢弃或合并为 runnerTrace 证据。判断 final response 是否存在时必须能从 `text``content``finalResponse``reply/finalText` 等权威字段提取非空文本,空对象、进度 assistant trace 文本或仅有 AgentRun completed 状态都不能 seal completed`/v1/agent/turns/:traceId`、Workbench read model、projection writer、runtime store invariant 和前端缓存层必须共用这个 terminal seal gate。新增修复应优先补 `workbench-message-projection-runtime``workbench-server-state` 或对应后端 read-model 的最小单元测试,而不是用 UI 特判、reload 或额外 fallback 掩盖投影分叉。
Workbench terminal 三态必须作为一个不可拆分的投影不变量维护:session rail 状态、turn 卡片状态和 final response object 要么同时表现为完成且 final response 存在,要么同时表现为运行且 final response 不存在。持久 read model 是 session、messages、turn、trace tuple 的 source of truth;前端 `workbench-server-state` 只能做归一化缓存和单调合并,不能把 stale running 刷新覆盖到同一 trace 的 terminal authority 上。`session.status``turn.status``session.list/detail/messages``message.snapshot`、projection page merge、detail/history trace reads 和 SSE trace snapshot 都必须保留已 sealed terminal 的 `status``traceAutoLifecycle``text``finalResponse` 和 terminal timing;新 trace 的 running 可以让同一 session 进入下一轮运行,但同 trace 的 running/non-terminal 只能作为旧事件丢弃或合并为 runnerTrace 证据。判断 final response 是否存在时必须能从 `text``content``finalResponse``reply/finalText` 等权威字段提取非空文本,空对象、进度 assistant trace 文本或仅有 AgentRun completed 状态都不能 seal completedWorkbench read model、`/v1/workbench/sync` replay、projection writer、runtime store invariant、显式 turn/detail API 和前端缓存层必须共用这个 terminal seal gate。新增修复应优先补 `workbench-message-projection-runtime``workbench-server-state` 或对应后端 read-model 的最小单元测试,而不是用 UI 特判、reload 或额外 fallback 掩盖投影分叉。
MDTODO 发起 Workbench 执行时,HWPOD 执行上下文的唯一权威来源是 Project Management source registry。Workbench Launch 服务端必须通过 `taskRef -> sourceId/fileRef -> source` 解析 `launchContext.executionContext`,并把同一份 `contextFingerprint` 写入 session owner、Workbench facts、project-management link 和 OTel span;浏览器传入的 HWPOD 字段只能作为任务元数据,不能作为权威执行上下文。`sourceKind=hwpod-workspace` 但缺少 `hwpodId``nodeId``workspaceRootRef` 时,launch 必须显式失败,不能创建“看似成功但无法执行”的空 session。
@@ -29,15 +29,15 @@ MDTODO 首轮 Code Agent prompt 必须包含 `hwpodId`、`mdtodoRootRef` 和 `hw
Cloud Web 的通用加载态使用 `web/hwlab-cloud-web/src/components/common/LoadingState.vue`。新增或修复页面加载态时优先复用该组件,并通过明确的 ready/loading 状态控制展示;不要在每个组件里重新实现一套 spinner、点状动画或默认占位数据。紧凑区域可以使用组件的 compact 形态,文案默认保持“加载中”。
Session rail 是该规则的高频区域。`/v1/agent/conversations` 还未返回时,即使 workspace 中已有 `selectedConversationId`、sessionId、traceId 或 selected conversation snapshot,也不能把选中 session stub 渲染成单条 `.session-tab`,更不能让它占满整个 session 列表高度。加载窗口应只显示 `LoadingState`,并隐藏当前 trace 元信息、复制/删除等依赖真实 active tab 的动作;待 conversations ready 后再渲染真实 session tabs,或在真实空集合时显示空态。
Session rail 是该规则的高频区域。`/v1/workbench/sessions` 还未返回时,即使 workspace 中已有 `selectedConversationId`、sessionId、traceId 或 selected conversation snapshot,也不能把选中 session stub 渲染成单条 `.session-tab`,更不能让它占满整个 session 列表高度。加载窗口应只显示 `LoadingState`,并隐藏当前 trace 元信息、复制/删除等依赖真实 active tab 的动作;待 Workbench session list ready 后再渲染真实 session tabs,或在真实空集合时显示空态。
Session rail 的后台恢复刷新必须有硬边界。显式用户动作或强一致操作(例如选择会话、删除当前会话)可以立即刷新会话列表;SSE error、active trace sync replay、terminal refresh、trace hydration 等后台补偿路径不得绕过 session list 的冷却/合并机制去强制刷新完整列表。后台路径应优先补当前 trace、turn status、message projection 和必要的 trace events;需要刷新 session rail 时走统一的 scheduled refresh,并按 session/list key 合并已有 timer,避免网络抖动或 EventSource error storm 把 `/v1/workbench/sessions` 放大成浏览器内存和 CDP responsiveness 红灯。
Session rail 的后台恢复刷新必须有硬边界。显式用户动作或强一致操作(例如选择会话、删除当前会话)可以立即刷新会话列表;SSE error、active trace sync replay、terminal refresh、detail/history trace reads 等后台补偿路径不得绕过 session list 的冷却/合并机制去强制刷新完整列表。后台路径应优先补当前 trace、turn status、message projection 和必要的 trace events;需要刷新 session rail 时走统一的 scheduled refresh,并按 session/list key 合并已有 timer,避免网络抖动或 EventSource error storm 把 `/v1/workbench/sessions` 放大成浏览器内存和 CDP responsiveness 红灯。
Workbench realtime 恢复与 sync replay 必须统一接入已经迁移的 OpenCode-style runtime 模块。`workbench-stream-transport` 只拥有 SSE lifecycle、cursor 和 recovery reason`workbench-realtime-plan` 只把 transport action 转成纯 plan`workbench-refresh-runtime`、keyed singleflight、scheduled task runtime、trace hydration queue、server-state reducer 和 session cache 是恢复读取的唯一 substrate。`workbench.ts` 不得再持有新的 in-flight map、timer map、cursor map、REST recovery queue 或第二套 recovery coordinator。`force` 是用户显式操作和恢复优先级语义,不得绕过同 key 的 singleflight、cooldown、min interval 或 storm budget;这些预算、退避、并发、页数、重试和窗口参数只由 node/lane YAML-backed runtime policy 注入,SPEC 只声明字段族、责任边界和验收读取方式,不写死数值。
Workbench realtime 恢复与 sync replay 必须统一接入已经迁移的 OpenCode-style runtime 模块。`workbench-stream-transport` 只拥有 SSE lifecycle、cursor 和 recovery reason`workbench-realtime-plan` 只把 transport action 转成纯 plan`workbench-refresh-runtime`、keyed singleflight、scheduled task runtime、trace history queue、server-state reducer 和 session cache 是恢复读取的唯一 substrate。`workbench.ts` 不得再持有新的 in-flight map、timer map、cursor map、detail-read recovery queue 或第二套 recovery coordinator。`force` 是用户显式操作和恢复优先级语义,不得绕过同 key 的 singleflight、cooldown、min interval 或 storm budget;这些预算、退避、并发、页数、重试和窗口参数只由 node/lane YAML-backed runtime policy 注入,SPEC 只声明字段族、责任边界和验收读取方式,不写死数值。
Workbench Realtime Authority v2 的自动恢复只允许消费 SSE typed event 和 `/v1/workbench/sync` replay。`/workbench/sync` 返回的 durable `delta.messages``delta.turns` 等 family object 必须在前端 authority 层投影成带 `realtimeAuthority`、entity family/id/version 和 projection revision 的 `message.snapshot``turn.snapshot` 等 typed event,再进入统一 reducer;不得把 family delta 当成不可应用的普通对象,也不得用 `/v1/workbench/sessions/:id/messages``/v1/workbench/turns/:id``/v1/workbench/traces/:id/events` 自动 fan-out 补洞。跨 tab/page 的 `session-projection` signal 只能触发同一 `/workbench/sync` replay 或临时 optimistic echo,最终必须由 durable sync delta 覆盖并让 control/observer 页在同一个 session 上收敛到相同 messages/turn projection。`workbench-server-state``message.snapshot` guard 只能拒绝无 messageId、无 trace 或当前会话没有同 trace 上下文的孤儿 agent snapshotdurable user fact 之后到达的同 trace agent snapshot 必须可追加。新增修复应覆盖“observer 从空消息状态应用 sync replay 后得到 user/agent messages 与 turn status”的最小测试,并用 web-probe `observe analyze` 确认没有 persistent `cross-page-projection-divergence` 或 automatic recovery legacy fan-out 红项。
Workbench 只能维护一条会话恢复与提交路径。首次打开、新建后继续、从左侧 session rail 切换、直接进入 `/workbench/sessions/<conversationId>` 恢复时,都必须以当前 route/active conversation id 作为会话真相,并通过同一条 conversation detail hydration 路径得到 messages、turn state、trace/status 和 markdown 渲染输入;不得另写只消费列表 snapshot、workspace stub 或 localStorage selected id 的恢复分支。
Workbench 只能维护一条会话恢复与提交路径。首次打开、新建后继续、从左侧 session rail 切换、直接进入 `/workbench/sessions/<conversationId>` 恢复时,都必须以当前 route/active conversation id 作为会话真相,并通过同一条 conversation detail read 路径得到 messages、turn state、trace/status 和 markdown 渲染输入;不得另写只消费列表 snapshot、workspace stub 或 localStorage selected id 的恢复分支。
Workbench 的 URL 反射必须服从用户当前导航和组件生命周期。`activeConversationId`、hydrate、select conversation 或列表刷新等异步状态只能在当前 route 仍属于 Workbench section、路径仍是 `/workbench`/`/workspace` 系列且 Workbench 组件仍 active 时,才允许把 URL 反射到 `/workbench/sessions/<conversationId>`;用户已经点击 Dashboard、API Keys、Admin、Settings 或其他非 Workbench 导航后,晚到的 Workbench 响应只能更新 store,不得再调用 `router.replace`/`router.push` 把全局 route 拉回 Workbench。新增 session 恢复或 URL 反射入口时必须复用共享路由守卫,例如 `web/hwlab-cloud-web/src/router/workbench-navigation.ts`,不要在业务组件里各自手写跳转判断。
+2 -2
View File
@@ -149,7 +149,7 @@ assertIncludes(workbenchColadaSource, "useMutation", "Workbench mutations must e
assertIncludes(workbenchStoreSource, "useWorkbenchColadaQueries", "Workbench store must read server-state through Colada query facade");
assertIncludes(workbenchStoreSource, "useWorkbenchColadaMutations", "Workbench store must run admission/cancel through Colada mutations");
assertIncludes(workbenchStoreSource, "useWorkbenchColadaReducer", "Workbench store must use Colada cache as projection reducer authority");
assert.doesNotMatch(workbenchStoreSource, /createWorkbench(?:ReadHydration|ScheduledTask|TraceHydrationQueue)Runtime|runWorkbenchReadHydration/u, "Workbench store must not use legacy Workbench request runtimes");
assert.doesNotMatch(workbenchStoreSource, /createWorkbench(?:ReadHydration|ReadDetail|ScheduledTask|TraceHydrationQueue|TraceDetailReadQueue)Runtime|runWorkbench(?:ReadHydration|ReadDetail)/u, "Workbench store must not use legacy Workbench request runtimes");
assert.doesNotMatch(workbenchStoreSource, /api\.workbench\.(?:sessions|sessionMessages|turn|traceEvents)/u, "Workbench store must not call Workbench read APIs outside Colada queries");
assertIncludes(workbenchEventReducerSource, "reduceWorkbenchRealtimeEvent", "Realtime event reducer must own SSE event classification");
assertIncludes(workbenchEventReducerSource, "workbench-event-reducer", "Realtime event reducer must emit module diagnostics for monitor root cause");
@@ -212,7 +212,7 @@ assert.doesNotMatch(serverWorkbenchHttpSource, /return\s+turnSnapshot\(context\)
assertIncludes(serverWorkbenchHttpSource, "blockedRealtimeTurnSnapshot", "Realtime read-model gaps must produce blocked diagnostic turn snapshots");
assertIncludes(serverWorkbenchHttpSource, "workbench_facts_session_missing", "Realtime read-model gaps must expose a facts-missing blocker");
assertIncludes(appSource, "/v1/workbench/events", "Workbench realtime client must use the RESTful same-origin events endpoint");
assertIncludes(appSource, "/v1/workbench/traces/", "trace hydration must use Workbench read-model trace API");
assertIncludes(appSource, "/v1/workbench/traces/", "trace detail read must use Workbench read-model trace API");
assert.doesNotMatch(appSource, /\/v1\/agent\/(?:turns|traces|chat\/result)\//u, "Cloud Web must not call legacy Code Agent read-through turn/trace/result APIs");
assert.doesNotMatch(workbenchStoreSource, /api\.(?:agent|workbench)\.(?:getAgentTurn|getAgentTrace|turn|traceEvents|sessions|sessionMessages)/u, "Workbench store turn/trace/session reads must go through Colada queries");
assert.doesNotMatch(appSource, /\/v1\/agent\/chat\/trace\//u, "Cloud Web must not use the legacy action-style chat trace API");
@@ -169,8 +169,8 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
if (traceMatch && method === "GET") {
const traceId = decodeURIComponent(traceMatch[1] ?? "");
if (state.scenarioId === "completed-replay-detail-404" && traceId === "trc_completed") return json(response, 404, { ok: false, status: 404, error: { code: "trace_replay_unavailable" } });
if (state.scenarioId === "trace-hydration-timeout-diagnostic" && traceId === "trc_trace_hydration_timeout") return errorDiagnosticResponse(response, 504, `/v1/workbench/traces/${traceId}/events`, "trace_hydration_timeout", "Trace 更新超时,运行记录暂不可见。", { traceId: "33333333333333333333333333333333", requestId: "req_e2e_trace_hydration_timeout", layer: "workbench-read-model", category: "trace-hydration" });
if (state.scenarioId === "sealed-final-response-diagnostics" && traceId === "trc_sealed_final_diag") return errorDiagnosticResponse(response, 504, `/v1/workbench/traces/${traceId}/events`, "trace_hydration_timeout", "Trace 更新超时,运行记录暂不可见。", { traceId: "55555555555555555555555555555555", requestId: "req_e2e_sealed_final_trace_timeout", layer: "workbench-read-model", category: "trace-hydration" });
if (state.scenarioId === "trace-detail-read-timeout-diagnostic" && traceId === "trc_trace_detail_read_timeout") return errorDiagnosticResponse(response, 504, `/v1/workbench/traces/${traceId}/events`, "trace_detail_read_timeout", "Trace 更新超时,运行记录暂不可见。", { traceId: "33333333333333333333333333333333", requestId: "req_e2e_trace_detail_read_timeout", layer: "workbench-read-model", category: "trace-detail-read" });
if (state.scenarioId === "sealed-final-response-diagnostics" && traceId === "trc_sealed_final_diag") return errorDiagnosticResponse(response, 504, `/v1/workbench/traces/${traceId}/events`, "trace_detail_read_timeout", "Trace 更新超时,运行记录暂不可见。", { traceId: "55555555555555555555555555555555", requestId: "req_e2e_sealed_final_trace_timeout", layer: "workbench-read-model", category: "trace-detail-read" });
if (traceId === state.staleTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } });
return json(response, 200, workbenchTracePayload(traceId, url));
}
@@ -1128,9 +1128,9 @@ function createScenarioState(scenarioId: string): ScenarioState {
sessions.unshift(projectionSseErrorSession());
traces.trc_projection_sse_error = projectionSseErrorTrace();
}
if (id === "trace-hydration-timeout-diagnostic") {
sessions.unshift(traceHydrationTimeoutSession());
traces.trc_trace_hydration_timeout = traceHydrationTimeoutTrace();
if (id === "trace-detail-read-timeout-diagnostic") {
sessions.unshift(traceDetailReadTimeoutSession());
traces.trc_trace_detail_read_timeout = traceDetailReadTimeoutTrace();
}
if (id === "sealed-final-response-diagnostics") {
sessions.unshift(sealedFinalResponseDiagnosticSession());
@@ -1179,8 +1179,8 @@ function createScenarioState(scenarioId: string): ScenarioState {
? "ses_projection_degraded"
: id === "projection-sse-error"
? "ses_projection_sse_error"
: id === "trace-hydration-timeout-diagnostic"
? "ses_trace_hydration_timeout"
: id === "trace-detail-read-timeout-diagnostic"
? "ses_trace_detail_read_timeout"
: id === "sealed-final-response-diagnostics"
? "ses_sealed_final_diag"
: id === "trace-final-response-not-message-authority"
@@ -1540,23 +1540,23 @@ function projectionSseErrorSession(): SessionRecord {
};
}
function traceHydrationTimeoutTrace(): JsonRecord {
return { traceId: "trc_trace_hydration_timeout", status: "running", sessionId: "ses_trace_hydration_timeout", threadId: "thr_trace_hydration_timeout", turnId: "turn_trace_hydration_timeout", events: [], eventCount: 0, fullTraceLoaded: false, hasMore: true };
function traceDetailReadTimeoutTrace(): JsonRecord {
return { traceId: "trc_trace_detail_read_timeout", status: "running", sessionId: "ses_trace_detail_read_timeout", threadId: "thr_trace_detail_read_timeout", turnId: "turn_trace_detail_read_timeout", events: [], eventCount: 0, fullTraceLoaded: false, hasMore: true };
}
function traceHydrationTimeoutSession(): SessionRecord {
function traceDetailReadTimeoutSession(): SessionRecord {
const now = new Date().toISOString();
return {
sessionId: "ses_trace_hydration_timeout",
threadId: "thr_trace_hydration_timeout",
sessionId: "ses_trace_detail_read_timeout",
threadId: "thr_trace_detail_read_timeout",
status: "running",
lastTraceId: "trc_trace_hydration_timeout",
lastTraceId: "trc_trace_detail_read_timeout",
updatedAt: now,
messageCount: 2,
firstUserMessagePreview: "trace hydration timeout visibility",
firstUserMessagePreview: "trace detail read timeout visibility",
messages: [
{ id: "msg_trace_hydration_timeout_user", messageId: "msg_trace_hydration_timeout_user", role: "user", title: "用户", text: "trace hydration timeout visibility", status: "sent", createdAt: now, sessionId: "ses_trace_hydration_timeout", threadId: "thr_trace_hydration_timeout", turnId: "turn_trace_hydration_timeout" },
{ id: "msg_trace_hydration_timeout_agent", messageId: "msg_trace_hydration_timeout_agent", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: now, sessionId: "ses_trace_hydration_timeout", threadId: "thr_trace_hydration_timeout", traceId: "trc_trace_hydration_timeout", turnId: "turn_trace_hydration_timeout", runnerTrace: traceHydrationTimeoutTrace() }
{ id: "msg_trace_detail_read_timeout_user", messageId: "msg_trace_detail_read_timeout_user", role: "user", title: "用户", text: "trace detail read timeout visibility", status: "sent", createdAt: now, sessionId: "ses_trace_detail_read_timeout", threadId: "thr_trace_detail_read_timeout", turnId: "turn_trace_detail_read_timeout" },
{ id: "msg_trace_detail_read_timeout_agent", messageId: "msg_trace_detail_read_timeout_agent", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: now, sessionId: "ses_trace_detail_read_timeout", threadId: "thr_trace_detail_read_timeout", traceId: "trc_trace_detail_read_timeout", turnId: "turn_trace_detail_read_timeout", runnerTrace: traceDetailReadTimeoutTrace() }
]
};
}
@@ -2063,7 +2063,7 @@ function liveBackfillEarlyTrace(sessionId: string, threadId: string | null, trac
function liveBackfillCompletedTrace(sessionId: string, threadId: string | null, traceId: string): JsonRecord {
const createdAt = new Date().toISOString();
const finalText = "fake AgentRun completed after REST backfill without terminal SSE.";
const finalText = "fake AgentRun completed after detail backfill without terminal SSE.";
const agentRun = liveBackfillAgentRun("completed", "completed", 23);
const events = [
...(liveBackfillEarlyTrace(sessionId, threadId, traceId).events as JsonRecord[]),
@@ -34,7 +34,7 @@ test("Workbench scoped keys encode delimiter characters", () => {
test("Workbench runtime policy reads injected config while preserving defaults", () => {
const policy = workbenchRuntimePolicy({
sessionListPageLimit: 7,
traceHydrationMaxPages: 2,
traceDetailMaxPages: 2,
workbenchSessionDetailMinRefreshMs: 1234,
workbenchSessionMessagesWindowLimit: 9,
workbenchTraceMessagesWindowLimit: 4,
@@ -46,7 +46,7 @@ test("Workbench runtime policy reads injected config while preserving defaults",
});
assert.equal(policy.sessionListPageLimit, 7);
assert.equal(policy.traceHydrationMaxPages, 2);
assert.equal(policy.traceDetailMaxPages, 2);
assert.equal(policy.workbenchSessionDetailMinRefreshMs, 1234);
assert.equal(policy.workbenchSessionMessagesWindowLimit, 9);
assert.equal(policy.workbenchTraceMessagesWindowLimit, 4);
@@ -57,6 +57,20 @@ test("Workbench runtime policy reads injected config while preserving defaults",
assert.equal(policy.defaultGatewayTimeoutMs, 120_000);
});
test("Workbench runtime policy keeps deprecated trace hydration aliases compatible", () => {
const policy = workbenchRuntimePolicy({
traceHydrationPageLimit: 13,
traceHydrationMaxPages: 2,
traceHydrationAutoQueueLimit: 3,
workbenchReadHydrationConcurrency: 4
});
assert.equal(policy.traceDetailPageLimit, 13);
assert.equal(policy.traceDetailMaxPages, 2);
assert.equal(policy.traceDetailAutoQueueLimit, 3);
assert.equal(policy.workbenchDetailReadConcurrency, 4);
});
test("Workbench API uses metadata-only session detail and bounded messages paths independently", () => {
assert.equal(workbenchSessionDetailPathForTest("ses_metadata"), "/v1/workbench/sessions/ses_metadata?includeMessages=false");
assert.equal(workbenchSessionDetailPathForTest("ses_metadata", { timeoutMs: 8000 }), "/v1/workbench/sessions/ses_metadata?includeMessages=false");
@@ -128,9 +128,9 @@ function messageActivityMeta(message: ChatMessage): { text: string; label: strin
function messageTimingForDisplay(message: ChatMessage): ChatMessage["timing"] {
// The main message card consumes only the durable message timing projection.
// Trace timing belongs to TraceTimeline/details; letting trace hydration drive the
// card timer makes running turns jump when a later trace snapshot carries an
// older startedAt/lastEventAt window.
// Trace timing belongs to TraceTimeline detail/history reads; letting those
// reads drive the card timer makes running turns jump when a later trace
// snapshot carries an older startedAt/lastEventAt window.
return message.timing ?? null;
}
@@ -4,15 +4,15 @@
export interface WorkbenchRuntimePolicy {
defaultCodeAgentTimeoutMs: number;
defaultGatewayTimeoutMs: number;
traceHydrationPageLimit: number;
traceHydrationMaxPages: number;
traceHydrationMaxAttempts: number;
traceHydrationRetryDelayMs: number;
traceHydrationAutoQueueLimit: number;
traceHydrationBackgroundConcurrency: number;
traceHydrationBackgroundDelayMs: number;
traceDetailPageLimit: number;
traceDetailMaxPages: number;
traceDetailMaxAttempts: number;
traceDetailRetryDelayMs: number;
traceDetailAutoQueueLimit: number;
traceDetailBackgroundConcurrency: number;
traceDetailBackgroundDelayMs: number;
sessionListPageLimit: number;
workbenchReadHydrationConcurrency: number;
workbenchDetailReadConcurrency: number;
workbenchReadFailureCooldownMs: number;
workbenchTurnStatusMinRefreshMs: number;
workbenchTraceEventsMinRefreshMs: number;
@@ -36,15 +36,15 @@ export interface WorkbenchRuntimePolicy {
const DEFAULT_WORKBENCH_RUNTIME_POLICY: WorkbenchRuntimePolicy = Object.freeze({
defaultCodeAgentTimeoutMs: 1_800_000,
defaultGatewayTimeoutMs: 120_000,
traceHydrationPageLimit: 50,
traceHydrationMaxPages: 4,
traceHydrationMaxAttempts: 3,
traceHydrationRetryDelayMs: 700,
traceHydrationAutoQueueLimit: 4,
traceHydrationBackgroundConcurrency: 1,
traceHydrationBackgroundDelayMs: 400,
traceDetailPageLimit: 50,
traceDetailMaxPages: 4,
traceDetailMaxAttempts: 3,
traceDetailRetryDelayMs: 700,
traceDetailAutoQueueLimit: 4,
traceDetailBackgroundConcurrency: 1,
traceDetailBackgroundDelayMs: 400,
sessionListPageLimit: 20,
workbenchReadHydrationConcurrency: 3,
workbenchDetailReadConcurrency: 3,
workbenchReadFailureCooldownMs: 5_000,
workbenchTurnStatusMinRefreshMs: 2_000,
workbenchTraceEventsMinRefreshMs: 4_000,
@@ -70,15 +70,15 @@ export function workbenchRuntimePolicy(input: unknown = runtimePolicyConfig()):
return {
defaultCodeAgentTimeoutMs: positiveNumber(source.defaultCodeAgentTimeoutMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.defaultCodeAgentTimeoutMs),
defaultGatewayTimeoutMs: positiveNumber(source.defaultGatewayTimeoutMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.defaultGatewayTimeoutMs),
traceHydrationPageLimit: positiveInteger(source.traceHydrationPageLimit, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceHydrationPageLimit),
traceHydrationMaxPages: positiveInteger(source.traceHydrationMaxPages, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceHydrationMaxPages),
traceHydrationMaxAttempts: positiveInteger(source.traceHydrationMaxAttempts, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceHydrationMaxAttempts),
traceHydrationRetryDelayMs: positiveNumber(source.traceHydrationRetryDelayMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceHydrationRetryDelayMs),
traceHydrationAutoQueueLimit: positiveInteger(source.traceHydrationAutoQueueLimit, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceHydrationAutoQueueLimit),
traceHydrationBackgroundConcurrency: positiveInteger(source.traceHydrationBackgroundConcurrency, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceHydrationBackgroundConcurrency),
traceHydrationBackgroundDelayMs: nonNegativeNumber(source.traceHydrationBackgroundDelayMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceHydrationBackgroundDelayMs),
traceDetailPageLimit: positiveInteger(source.traceDetailPageLimit ?? source.traceHydrationPageLimit, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceDetailPageLimit),
traceDetailMaxPages: positiveInteger(source.traceDetailMaxPages ?? source.traceHydrationMaxPages, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceDetailMaxPages),
traceDetailMaxAttempts: positiveInteger(source.traceDetailMaxAttempts ?? source.traceHydrationMaxAttempts, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceDetailMaxAttempts),
traceDetailRetryDelayMs: positiveNumber(source.traceDetailRetryDelayMs ?? source.traceHydrationRetryDelayMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceDetailRetryDelayMs),
traceDetailAutoQueueLimit: positiveInteger(source.traceDetailAutoQueueLimit ?? source.traceHydrationAutoQueueLimit, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceDetailAutoQueueLimit),
traceDetailBackgroundConcurrency: positiveInteger(source.traceDetailBackgroundConcurrency ?? source.traceHydrationBackgroundConcurrency, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceDetailBackgroundConcurrency),
traceDetailBackgroundDelayMs: nonNegativeNumber(source.traceDetailBackgroundDelayMs ?? source.traceHydrationBackgroundDelayMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceDetailBackgroundDelayMs),
sessionListPageLimit: positiveInteger(source.sessionListPageLimit, DEFAULT_WORKBENCH_RUNTIME_POLICY.sessionListPageLimit),
workbenchReadHydrationConcurrency: positiveInteger(source.workbenchReadHydrationConcurrency, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchReadHydrationConcurrency),
workbenchDetailReadConcurrency: positiveInteger(source.workbenchDetailReadConcurrency ?? source.workbenchReadHydrationConcurrency, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchDetailReadConcurrency),
workbenchReadFailureCooldownMs: positiveNumber(source.workbenchReadFailureCooldownMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchReadFailureCooldownMs),
workbenchTurnStatusMinRefreshMs: nonNegativeNumber(source.workbenchTurnStatusMinRefreshMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchTurnStatusMinRefreshMs),
workbenchTraceEventsMinRefreshMs: nonNegativeNumber(source.workbenchTraceEventsMinRefreshMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchTraceEventsMinRefreshMs),
@@ -141,7 +141,7 @@ test("workbench active terminal paths seal final response from turn authority",
const completeBlock = source.slice(source.indexOf("function completeTrace"), source.indexOf("async function hydrateTerminalMessageDiagnostics"));
const realtimeSessionSyncBlock = source.slice(source.indexOf("async function refreshRealtimeSessionFromSyncReplay"), source.indexOf("function completeTrace"));
const sessionDetailReadBlock = source.slice(source.indexOf("function fetchSessionDetailPage"), source.indexOf("function sessionMessageProjectionWindowLimit"));
const traceHydrationBlock = source.slice(source.indexOf("async function hydrateTraceEventsForExplicitDetailPages"), source.indexOf("async function fetchTraceHydrationPage"));
const traceDetailReadBlock = source.slice(source.indexOf("async function readTraceEventsForExplicitDetailPages"), source.indexOf("async function fetchTraceDetailEventsPage"));
const loadBlock = source.slice(source.indexOf("async function loadWorkbenchSession"), source.indexOf("async function sealRestoredActiveTurnMessages"));
const restoreSealBlock = source.slice(source.indexOf("async function sealRestoredActiveTurnMessages"), source.indexOf("function reattachRestoredActiveTrace"));
const activeSyncReplayBlock = source.slice(source.indexOf("async function refreshActiveTraceFromSyncReplay"), source.indexOf("function stopRealtime"));
@@ -150,7 +150,7 @@ test("workbench active terminal paths seal final response from turn authority",
assert.match(projectBlock, /type:\s*"message\.upsert"/u);
assert.match(projectBlock, /\.\.\.terminalPatch/u);
assert.match(source, /createKeyedSingleflight<ApiResult<AgentChatResultResponse>>/u);
assert.match(source, /traceHydrationSingleflight\.run/u);
assert.match(source, /traceDetailReadSingleflight\.run/u);
assert.doesNotMatch(source, new RegExp(["refresh", "RealtimeSessionMessages"].join(""), "u"));
assert.match(realtimeTurnBlock, /rememberTurnStatus\(traceId, result\)[\s\S]*scheduleRealtimeTurnProjection\(\{ traceId, result, terminalTurn \}\)/u);
assert.doesNotMatch(realtimeTurnBlock, new RegExp(`applyTurnStatusSnapshot\\(|${["refresh", "TerminalTraceFromRest"].join("")}\\(`, "u"));
@@ -158,7 +158,7 @@ test("workbench active terminal paths seal final response from turn authority",
assert.match(realtimeTurnProjectionBlock, /next\.terminalTurn[\s\S]*refreshTerminalTraceFromSyncReplay\(next\.traceId, "realtime-turn-snapshot"\)/u);
assert.match(realtimeTurnProjectionBlock, /workbench_realtime_turn_projection_budget/u);
assert.match(terminalSyncBlock, /refreshWorkbenchSyncReplay\(ownerSessionId, id, null, `terminal-sync-replay:\$\{reason\}`\)/u);
assert.doesNotMatch(terminalSyncBlock, /refreshTurnStatusByTraceId|refreshMessageProjectionForTrace|fetchSessionMessagesPage|hydrateTraceEventsForMessage/u);
assert.doesNotMatch(terminalSyncBlock, /refreshTurnStatusByTraceId|refreshMessageProjectionForTrace|fetchSessionMessagesPage|readTraceEventsForMessage/u);
assert.match(completeBlock, /projectTurnAuthorityToMessages\(traceId, result, "complete-trace"\)/u);
assert.match(completeBlock, /options\.forceRead[\s\S]*refreshMessageProjectionForTrace\(ownerSessionId, traceId, \{ force: true \}\)/u);
assert.match(completeBlock, /else scheduleActiveTraceSyncReplay\(traceId, "complete-trace-sync-replay", 0\)/u);
@@ -167,10 +167,10 @@ test("workbench active terminal paths seal final response from turn authority",
assert.doesNotMatch(realtimeSessionSyncBlock, /hydrateTurnStatusAuthority|sessionDetailAutoReadDecision|fetchSessionDetailPage|fetchSessionMessagesPage|refreshMessageProjectionForTrace/u);
assert.doesNotMatch(realtimeSessionSyncBlock, /loadWorkbenchSession|applySelectedSessionDetail/u);
assert.match(activeSyncReplayBlock, /refreshWorkbenchSyncReplay\(ownerSessionId, id, null, `active-sync-replay:\$\{reason\}`\)/u);
assert.doesNotMatch(activeSyncReplayBlock, /refreshTurnStatusByTraceId|refreshMessageProjectionForTrace|fetchSessionMessagesPage|hydrateTraceEventsForMessage/u);
assert.match(traceHydrationBlock, /traceAuthorityById\.value\[traceId\] \?\? message\.runnerTrace/u);
assert.match(traceHydrationBlock, /traceEventsHydrationReadDecision\(traceId, afterProjectedSeq, message, options\)/u);
assert.match(traceHydrationBlock, /trace_events_auto_read_skip/u);
assert.doesNotMatch(activeSyncReplayBlock, /refreshTurnStatusByTraceId|refreshMessageProjectionForTrace|fetchSessionMessagesPage|readTraceEventsForMessage/u);
assert.match(traceDetailReadBlock, /traceAuthorityById\.value\[traceId\] \?\? message\.runnerTrace/u);
assert.match(traceDetailReadBlock, /traceEventsDetailReadDecision\(traceId, afterProjectedSeq, message, options\)/u);
assert.match(traceDetailReadBlock, /trace_events_auto_read_skip/u);
assert.match(sessionDetailReadBlock, /workbenchSessionDetailReadKey\(\{ sessionId, force: options\.force \}\)/u);
assert.match(sessionDetailReadBlock, /fetchSession\(sessionId, \{ includeMessages: false,/u);
assert.match(loadBlock, /const messageLimit = sessionMessageProjectionWindowLimit\(\);/u);
@@ -196,5 +196,14 @@ test("workbench automatic live repair does not call legacy REST fan-out", () =>
assert.match(automaticBlocks, /refreshWorkbenchSyncReplay/u);
assert.doesNotMatch(automaticBlocks, /fetchWorkbenchTurnStatus|fetchWorkbenchTraceEvents|fetchSessionMessagesPage|fetchSessionDetailPage/u);
assert.doesNotMatch(automaticBlocks, /refreshTurnStatusByTraceId|refreshMessageProjectionForTrace|hydrateTraceEventsForMessage\(/u);
assert.doesNotMatch(automaticBlocks, /refreshTurnStatusByTraceId|refreshMessageProjectionForTrace|readTraceEventsForMessage\(/u);
});
test("cross page projection signal always replays durable sync", () => {
const source = fs.readFileSync(path.join(storeDir, "workbench.ts"), "utf8");
const signalBlock = source.slice(source.indexOf("function handleWorkbenchProjectionSignal"), source.indexOf("function applyTraceSnapshot"));
assert.match(signalBlock, /refreshRealtimeSessionFromSyncReplay\(sessionId, `cross-tab-session-projection:/u);
assert.doesNotMatch(signalBlock, /messages\.value\.some/u);
assert.doesNotMatch(signalBlock, /return;\s*\n\s*void refreshRealtimeSessionFromSyncReplay/u);
});
@@ -473,7 +473,7 @@ export function shouldSuppressTransientWorkbenchReadFailure(result: ApiResult<un
const code = firstStringOrNumber(result.apiError?.code, diagnostic?.code);
const category = firstNonEmptyString(result.apiError?.category, diagnostic?.category);
const source = firstNonEmptyString(result.apiError?.source, diagnostic?.source);
if (code === "workbench_read_hydration_cooldown" || code === "workbench_read_hydration_throttled") return true;
if (code === "workbench_detail_read_cooldown" || code === "workbench_detail_read_throttled" || code === "workbench_read_hydration_cooldown" || code === "workbench_read_hydration_throttled") return true;
return result.status === 0 && source === "browser" && code === "browser_network_error" && category === "network";
}
@@ -492,11 +492,11 @@ export function messageNeedsTerminalDiagnostics(message: ChatMessage): boolean {
return !agentRun || (message.status !== "completed" && !error);
}
export function messageNeedsTraceHydration(message: ChatMessage): boolean {
export function messageNeedsTraceDetailRead(message: ChatMessage): boolean {
if (message.role !== "agent") return false;
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
if (!traceId) return false;
if (!messageHasActiveTraceForHydration(message)) return false;
if (!messageHasActiveTraceForDetailRead(message)) return false;
const trace = message.runnerTrace;
const events = Array.isArray(trace?.events) ? trace.events : [];
const eventCount = firstFiniteNumber(trace?.eventCount) ?? events.length;
@@ -504,7 +504,7 @@ export function messageNeedsTraceHydration(message: ChatMessage): boolean {
return events.length === 0 || trace?.eventsCompacted === true || trace?.fullTraceLoaded !== true;
}
function messageHasActiveTraceForHydration(message: ChatMessage): boolean {
function messageHasActiveTraceForDetailRead(message: ChatMessage): boolean {
const trace = message.runnerTrace;
return isTraceActiveStatus(message.status)
|| isTraceActiveStatus(trace?.status)
@@ -1,5 +1,5 @@
// SPEC: pikasTech/HWLAB#2356 Workbench bounded request storm follow-up.
// Responsibility: Pure budgeting helpers for Workbench read hydration de-duplication and projection merge writes.
// Responsibility: Pure budgeting helpers for Workbench detail read de-duplication and projection merge writes.
import type { ChatMessage } from "@/types";
import { firstNonEmptyString } from "@/utils";
@@ -47,7 +47,7 @@ export interface SessionDetailAutoReadDecisionInput {
minIntervalMs: number;
}
export interface ReadHydrationDecision {
export interface ReadDetailDecision {
read: boolean;
reason: string;
}
@@ -79,7 +79,7 @@ export function workbenchTraceEventsReadKey(input: TraceEventsReadRangeInput): s
);
}
export function traceEventsAutoReadDecision(input: TraceEventsAutoReadDecisionInput): ReadHydrationDecision {
export function traceEventsAutoReadDecision(input: TraceEventsAutoReadDecisionInput): ReadDetailDecision {
if (input.force === true) return { read: true, reason: "force" };
if (input.terminalBodyVisible === true) return { read: false, reason: "terminal-visible" };
const afterProjectedSeq = readSeqPart(input.afterProjectedSeq);
@@ -89,7 +89,7 @@ export function traceEventsAutoReadDecision(input: TraceEventsAutoReadDecisionIn
return { read: true, reason: "delta-range" };
}
export function sessionDetailAutoReadDecision(input: SessionDetailAutoReadDecisionInput): ReadHydrationDecision {
export function sessionDetailAutoReadDecision(input: SessionDetailAutoReadDecisionInput): ReadDetailDecision {
if (input.force === true) return { read: true, reason: "force" };
if (input.terminalBodyVisible === true) return { read: false, reason: "terminal-visible" };
if (firstNonEmptyString(input.traceId)) return { read: false, reason: "turn-authority-first" };
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { test } from "bun:test";
import type { ChatMessage, WorkbenchSessionRecord } from "../types";
import { resolveCancelableAgentMessage, resolveComposerState, selectActiveTurnStatusRefreshTraceIds, sessionToSessionTab, shouldHydrateTerminalTraceFromRest } from "./workbench-session";
import { resolveCancelableAgentMessage, resolveComposerState, selectActiveTurnStatusRefreshTraceIds, sessionToSessionTab, shouldReadTerminalTraceDetail } from "./workbench-session";
import type { TurnStatusAuthority } from "./workbench-session";
function agentMessage(input: Partial<ChatMessage> & Pick<ChatMessage, "id" | "traceId" | "sessionId">): ChatMessage {
@@ -94,22 +94,22 @@ test("turn status refresh selector keeps automatic hydrate bounded to the active
});
test("turn status refresh selector skips sealed terminal traces", () => {
const sessionId = "ses_turn_hydrate_sealed";
const sessionId = "ses_turn_detail_sealed";
const sealed = agentMessage({ id: "msg_sealed", traceId: "trc_sealed", sessionId, status: "completed", text: "final" });
assert.deepEqual(selectActiveTurnStatusRefreshTraceIds({ messages: [sealed], currentRequestTraceId: sealed.traceId, turnStatusAuthority: { [sealed.traceId]: turnStatus({ traceId: sealed.traceId, sessionId, status: "completed", running: false, terminal: true }) }, limit: 1 }), []);
});
test("terminal REST hydrate skips sealed terminal trace authority", () => {
const sessionId = "ses_terminal_hydrate_sealed";
const traceId = "trc_terminal_hydrate_sealed";
const sealed = agentMessage({ id: "msg_terminal_hydrate_sealed", traceId, sessionId, status: "completed", text: "final response" });
const unsealed = agentMessage({ id: "msg_terminal_hydrate_unsealed", traceId, sessionId, status: "completed" });
test("terminal detail read skips sealed terminal trace authority", () => {
const sessionId = "ses_terminal_detail_sealed";
const traceId = "trc_terminal_detail_sealed";
const sealed = agentMessage({ id: "msg_terminal_detail_sealed", traceId, sessionId, status: "completed", text: "final response" });
const unsealed = agentMessage({ id: "msg_terminal_detail_unsealed", traceId, sessionId, status: "completed" });
const turnStatusAuthority = { [traceId]: turnStatus({ traceId, sessionId, status: "completed", running: false, terminal: true }) };
assert.equal(shouldHydrateTerminalTraceFromRest({ traceId, messages: [sealed], turnStatusAuthority }), false);
assert.equal(shouldHydrateTerminalTraceFromRest({ traceId, messages: [unsealed], turnStatusAuthority }), true);
assert.equal(shouldHydrateTerminalTraceFromRest({ traceId, messages: [], turnStatusAuthority }), true);
assert.equal(shouldReadTerminalTraceDetail({ traceId, messages: [sealed], turnStatusAuthority }), false);
assert.equal(shouldReadTerminalTraceDetail({ traceId, messages: [unsealed], turnStatusAuthority }), true);
assert.equal(shouldReadTerminalTraceDetail({ traceId, messages: [], turnStatusAuthority }), true);
});
test("session rail status prefers newer running trace over stale completed authority", () => {
@@ -129,7 +129,7 @@ export function selectActiveTurnStatusRefreshTraceIds(input: { messages: ChatMes
return [...traceIds];
}
export function shouldHydrateTerminalTraceFromRest(input: { traceId?: string | null; messages: ChatMessage[]; turnStatusAuthority?: TurnStatusAuthorityMap }): boolean {
export function shouldReadTerminalTraceDetail(input: { traceId?: string | null; messages: ChatMessage[]; turnStatusAuthority?: TurnStatusAuthorityMap }): boolean {
const traceId = firstNonEmptyString(input.traceId);
if (!traceId) return false;
const message = latestMessageForTrace(input.messages, traceId);
@@ -2,9 +2,9 @@ import assert from "node:assert/strict";
import { test } from "bun:test";
import type { AgentChatResultResponse, ChatMessage } from "../types";
import { terminalSealResultWithoutTraceEvents, traceHydrationProjectedSeq, traceNextProjectedSeq } from "./workbench-trace-hydration";
import { terminalSealResultWithoutTraceEvents, traceDetailProjectedSeq, traceNextProjectedSeq } from "./workbench-trace-detail";
test("trace hydration cursor prefers range metadata over scanning events", () => {
test("trace detail read cursor prefers range metadata over scanning events", () => {
const trace: ChatMessage["runnerTrace"] = {
traceId: "trc_cursor_metadata",
nextProjectedSeq: 42,
@@ -12,7 +12,7 @@ test("trace hydration cursor prefers range metadata over scanning events", () =>
events: [{ projectedSeq: 1, type: "event" }]
};
assert.equal(traceHydrationProjectedSeq(trace), 42);
assert.equal(traceDetailProjectedSeq(trace), 42);
});
test("trace result next cursor uses metadata before event fallback", () => {
@@ -1,14 +1,14 @@
import type { AgentChatResultResponse, ChatMessage, TraceEvent } from "../types";
export function traceHydrationProjectedSeq(trace: ChatMessage["runnerTrace"]): number {
const cursor = traceHydrationCursor(trace);
export function traceDetailProjectedSeq(trace: ChatMessage["runnerTrace"]): number {
const cursor = traceDetailCursor(trace);
if (cursor !== null) return cursor;
const events = Array.isArray(trace?.events) ? trace.events : [];
return eventsProjectedSeq(events, 0);
}
export function traceNextProjectedSeq(result: AgentChatResultResponse, fallback: number): number {
const cursor = traceHydrationCursor(result);
const cursor = traceDetailCursor(result);
if (cursor !== null) return cursor;
const events = Array.isArray(result.events) ? result.events : Array.isArray(result.traceEvents) ? result.traceEvents : [];
return eventsProjectedSeq(events, fallback);
@@ -21,7 +21,7 @@ export function terminalSealResultWithoutTraceEvents(result: AgentChatResultResp
return { ...rest, runnerTrace: traceRest };
}
function traceHydrationCursor(source: { nextProjectedSeq?: unknown; range?: { toProjectedSeq?: unknown } | null } | null | undefined): number | null {
function traceDetailCursor(source: { nextProjectedSeq?: unknown; range?: { toProjectedSeq?: unknown } | null } | null | undefined): number | null {
return finiteProjectedSeq(source?.nextProjectedSeq) ?? finiteProjectedSeq(source?.range?.toProjectedSeq);
}
+51 -52
View File
@@ -16,7 +16,7 @@ import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, Ap
import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId, normalizeWorkbenchSessionRouteId } from "@/utils";
import { composeWorkbenchScopedKey } from "@/utils/workbench-key";
import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, recordWorkbenchLoadingState, recordWorkbenchRuntimeDiagnostic, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance";
import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectActiveTurnStatusRefreshTraceIds, shouldHydrateTerminalTraceFromRest, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session";
import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectActiveTurnStatusRefreshTraceIds, shouldReadTerminalTraceDetail, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session";
import { initialWorkbenchSessionIdFromLocation } from "./workbench-projection";
import { cleanupWorkbenchServerStateSessions, selectActiveMessages, selectActiveSession, selectSessionList, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state";
import { cleanupDroppedWorkbenchSessionCaches, trimWorkbenchSessionCache } from "./workbench-session-cache";
@@ -36,7 +36,7 @@ import {
isTraceActiveStatus,
messageHasTerminalResponse,
messageNeedsTerminalDiagnostics,
messageNeedsTraceHydration,
messageNeedsTraceDetailRead,
messageStatusPatchForTerminalMerge,
terminalAuthorityMessageFromTurnResult,
terminalMessagePatchFromTurnResult,
@@ -72,7 +72,7 @@ import { useWorkbenchColadaMutations } from "./workbench-colada-mutations";
import { useWorkbenchColadaQueries } from "./workbench-colada-queries";
import { useWorkbenchColadaReducer } from "./workbench-colada-reducer";
import { projectionMergeCommitSummary, traceEventsAutoReadDecision, workbenchSessionDetailReadKey, workbenchSessionMessagesReadKey, workbenchTraceEventsReadKey, type TraceEventsReadRangeRecord } from "./workbench-session-messages-read-budget";
import { terminalSealResultWithoutTraceEvents, traceHydrationProjectedSeq, traceNextProjectedSeq } from "./workbench-trace-hydration";
import { terminalSealResultWithoutTraceEvents, traceDetailProjectedSeq, traceNextProjectedSeq } from "./workbench-trace-detail";
const WORKBENCH_SESSION_PROJECTION_SIGNAL_CHANNEL = "hwlab.workbench.sessionProjection.v1";
const WORKBENCH_SESSION_PROJECTION_SIGNAL_KEY = "hwlab.workbench.sessionProjectionSignal.v1";
@@ -111,7 +111,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const workbenchColadaQueries = useWorkbenchColadaQueries();
const workbenchColadaMutations = useWorkbenchColadaMutations();
const turnStatusReadSingleflight = createKeyedSingleflight<ApiResult<AgentChatResultResponse>>();
const traceHydrationSingleflight = createKeyedSingleflight<void>();
const traceDetailReadSingleflight = createKeyedSingleflight<void>();
const sessionMessagesReadSingleflight = createKeyedSingleflight<ApiResult<WorkbenchMessagePageResponse>>();
const sessionDetailReadSingleflight = createKeyedSingleflight<ApiResult<WorkbenchSessionDetailResponse>>();
const traceEventsReadRanges = new Map<string, TraceEventsReadRangeRecord>();
@@ -479,7 +479,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (!id) return false;
const ownerSessionId = normalizeWorkbenchSessionId(sessionId) ?? traceOwnerSessionId(id, null);
const source = ownerSessionId ? serverState.value.messagesBySessionId[ownerSessionId] ?? [] : messages.value;
return !shouldHydrateTerminalTraceFromRest({ traceId: id, messages: source, turnStatusAuthority: turnStatusAuthority.value });
return !shouldReadTerminalTraceDetail({ traceId: id, messages: source, turnStatusAuthority: turnStatusAuthority.value });
}
function appendActiveMessages(...items: ChatMessage[]): void {
@@ -513,7 +513,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
function fetchWorkbenchTraceEvents(traceId: string, afterProjectedSeq: number, useActivityTimeout = shouldUseActivityTimeoutForTrace(traceId), options: { force?: boolean } = {}): Promise<ApiResult<AgentChatResultResponse>> {
const activitySource = useActivityTimeout ? () => activityRef.value : null;
return workbenchColadaQueries.fetchTraceEvents(traceId, { timeoutMs: runtimePolicy.workbenchTraceEventsTimeoutMs, activityRef: activitySource, afterProjectedSeq, limit: runtimePolicy.traceHydrationPageLimit, minIntervalMs: runtimePolicy.workbenchTraceEventsMinRefreshMs, force: options.force });
return workbenchColadaQueries.fetchTraceEvents(traceId, { timeoutMs: runtimePolicy.workbenchTraceEventsTimeoutMs, activityRef: activitySource, afterProjectedSeq, limit: runtimePolicy.traceDetailPageLimit, minIntervalMs: runtimePolicy.workbenchTraceEventsMinRefreshMs, force: options.force });
}
function shouldUseActivityTimeoutForTrace(traceId: string | null | undefined): boolean {
@@ -543,7 +543,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const merged = mergeMessageProjectionPage(id, pageMessages, { traceId, limit: traceMessageProjectionWindowLimit() });
commitMergedSessionMessages(id, pageMessages, merged, { traceId, limit: traceMessageProjectionWindowLimit(), reason: "trace-message-page" });
await hydrateTurnStatusAuthority(merged, { traceId, limit: 1, reason: "trace-message-page" });
if (!traceProjectionIsTerminalSealed(traceId, merged)) hydrateTerminalTraceGaps(merged, `trace-message-page:${traceId}`);
if (!traceProjectionIsTerminalSealed(traceId, merged)) readTerminalTraceDetailGaps(merged, `trace-message-page:${traceId}`);
}
function fetchSessionMessagesPage(sessionId: string, options: SessionMessagesReadOptions): Promise<ApiResult<WorkbenchMessagePageResponse>> {
@@ -561,7 +561,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
}
function traceMessageProjectionWindowLimit(): number {
return boundedProjectionMessageLimit(runtimePolicy.workbenchTraceMessagesWindowLimit, runtimePolicy.traceHydrationAutoQueueLimit);
return boundedProjectionMessageLimit(runtimePolicy.workbenchTraceMessagesWindowLimit, runtimePolicy.traceDetailAutoQueueLimit);
}
function mergeMessageProjectionPage(sessionId: string, pageMessages: ChatMessage[], options: { traceId?: string | null; limit: number }): ChatMessage[] {
@@ -803,7 +803,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
threadId,
status: "running"
};
void hydrateTraceEvents(messages.value);
void readTraceEventsForMessages(messages.value);
scheduleSessionListRefresh(sessionId, runtimePolicy.sessionListTerminalRefreshDelayMs);
restartRealtime("steer");
return true;
@@ -865,57 +865,57 @@ export const useWorkbenchStore = defineStore("workbench", () => {
await submitMessage(retryInput);
}
async function hydrateTraceEventsForMessage(message: ChatMessage, options: { force?: boolean } = {}): Promise<void> {
async function readTraceEventsForMessage(message: ChatMessage, options: { force?: boolean } = {}): Promise<void> {
const traceId = message.traceId ?? message.runnerTrace?.traceId;
if (!traceId) return;
if (options.force !== true) {
recordWorkbenchRuntimeDiagnostic({ module: "workbench-trace-events-read", sessionId: message.sessionId ?? message.runnerTrace?.sessionId ?? null, traceId, outcome: "ok", diagnostic: { code: "trace_events_auto_read_disabled", reason: "sync-replay-authority", source: "trace-hydration", valuesRedacted: true } });
recordWorkbenchRuntimeDiagnostic({ module: "workbench-trace-events-read", sessionId: message.sessionId ?? message.runnerTrace?.sessionId ?? null, traceId, outcome: "ok", diagnostic: { code: "trace_events_auto_read_disabled", reason: "sync-replay-authority", source: "trace-detail-read", valuesRedacted: true } });
return;
}
await hydrateTraceEventsForMessageNow(message, options);
await readTraceEventsForMessageNow(message, options);
}
async function hydrateTraceEventsForMessageNow(message: ChatMessage, options: { force?: boolean } = {}): Promise<void> {
async function readTraceEventsForMessageNow(message: ChatMessage, options: { force?: boolean } = {}): Promise<void> {
const traceId = message.traceId ?? message.runnerTrace?.traceId;
if (!traceId) return;
if (traceTerminalBodyIsVisible(traceId, message.sessionId ?? message.runnerTrace?.sessionId)) {
recordWorkbenchRuntimeDiagnostic({ module: "workbench-terminal-priority", sessionId: message.sessionId ?? null, traceId, outcome: "ok", diagnostic: { code: "terminal_low_priority_trace_skip", source: "trace-hydration", valuesRedacted: true } });
recordWorkbenchRuntimeDiagnostic({ module: "workbench-terminal-priority", sessionId: message.sessionId ?? null, traceId, outcome: "ok", diagnostic: { code: "terminal_low_priority_trace_skip", source: "trace-detail-read", valuesRedacted: true } });
return;
}
await traceHydrationSingleflight.run(traceId, async () => {
await traceDetailReadSingleflight.run(traceId, async () => {
if (traceTerminalBodyIsVisible(traceId, message.sessionId ?? message.runnerTrace?.sessionId)) return;
await hydrateTraceEventsForExplicitDetailPages(message, options);
}, { reason: options.force ? "force-trace-hydration" : "trace-hydration" });
await readTraceEventsForExplicitDetailPages(message, options);
}, { reason: options.force ? "force-trace-detail-read" : "trace-detail-read" });
}
async function hydrateTraceEventsForExplicitDetailPages(message: ChatMessage, options: { force?: boolean } = {}): Promise<void> {
async function readTraceEventsForExplicitDetailPages(message: ChatMessage, options: { force?: boolean } = {}): Promise<void> {
const traceId = message.traceId ?? message.runnerTrace?.traceId;
if (!traceId) return;
let afterProjectedSeq = traceHydrationProjectedSeq(traceAuthorityById.value[traceId] ?? message.runnerTrace);
for (let page = 0; page < runtimePolicy.traceHydrationMaxPages; page += 1) {
let afterProjectedSeq = traceDetailProjectedSeq(traceAuthorityById.value[traceId] ?? message.runnerTrace);
for (let page = 0; page < runtimePolicy.traceDetailMaxPages; page += 1) {
if (traceTerminalBodyIsVisible(traceId, message.sessionId ?? message.runnerTrace?.sessionId)) return;
const decision = traceEventsHydrationReadDecision(traceId, afterProjectedSeq, message, options);
const decision = traceEventsDetailReadDecision(traceId, afterProjectedSeq, message, options);
if (!decision.read) {
recordTraceEventsHydrationSkip(traceId, message, decision.reason, afterProjectedSeq);
recordTraceEventsDetailReadSkip(traceId, message, decision.reason, afterProjectedSeq);
return;
}
const result = await fetchTraceHydrationPage(traceId, afterProjectedSeq, { force: options.force });
const result = await fetchTraceDetailEventsPage(traceId, afterProjectedSeq, { force: options.force });
if (!result.ok || !result.data) {
if (shouldSuppressTransientWorkbenchReadFailure(result)) return;
if (messageHasTerminalResponse(message)) return;
applyProjectionDiagnostic(traceId, projectionDiagnosticFromApiFailure(result, { code: "trace_hydration_failed", message: result.error ?? "Trace 更新超时,运行记录暂不可见。", health: result.status === 0 ? "unavailable" : "degraded" }));
applyProjectionDiagnostic(traceId, projectionDiagnosticFromApiFailure(result, { code: "trace_detail_read_failed", message: result.error ?? "Trace 更新超时,运行记录暂不可见。", health: result.status === 0 ? "unavailable" : "degraded" }));
return;
}
applyTraceHydrationResult(traceId, result.data);
applyTraceDetailEventsResult(traceId, result.data);
const nextProjectedSeq = traceNextProjectedSeq(result.data, afterProjectedSeq);
rememberTraceEventsHydrationRead(traceId, afterProjectedSeq, nextProjectedSeq, result.data);
rememberTraceEventsDetailRead(traceId, afterProjectedSeq, nextProjectedSeq, result.data);
if (result.data.hasMore !== true || nextProjectedSeq <= afterProjectedSeq) return;
afterProjectedSeq = nextProjectedSeq;
}
}
function traceEventsHydrationReadDecision(traceId: string, afterProjectedSeq: number, message: ChatMessage, options: { force?: boolean }): { read: boolean; reason: string } {
const limit = runtimePolicy.traceHydrationPageLimit;
function traceEventsDetailReadDecision(traceId: string, afterProjectedSeq: number, message: ChatMessage, options: { force?: boolean }): { read: boolean; reason: string } {
const limit = runtimePolicy.traceDetailPageLimit;
const key = workbenchTraceEventsReadKey({ traceId, afterProjectedSeq, limit });
return traceEventsAutoReadDecision({
traceId,
@@ -927,46 +927,46 @@ export const useWorkbenchStore = defineStore("workbench", () => {
});
}
function rememberTraceEventsHydrationRead(traceId: string, afterProjectedSeq: number, nextProjectedSeq: number, result: AgentChatResultResponse): void {
const limit = runtimePolicy.traceHydrationPageLimit;
function rememberTraceEventsDetailRead(traceId: string, afterProjectedSeq: number, nextProjectedSeq: number, result: AgentChatResultResponse): void {
const limit = runtimePolicy.traceDetailPageLimit;
traceEventsReadRanges.set(workbenchTraceEventsReadKey({ traceId, afterProjectedSeq, limit }), { traceId, afterProjectedSeq, limit, nextProjectedSeq, hasMore: result.hasMore === true });
}
function recordTraceEventsHydrationSkip(traceId: string, message: ChatMessage, reason: string, afterProjectedSeq: number): void {
function recordTraceEventsDetailReadSkip(traceId: string, message: ChatMessage, reason: string, afterProjectedSeq: number): void {
recordWorkbenchRuntimeDiagnostic({
module: "workbench-trace-events-read",
sessionId: message.sessionId ?? message.runnerTrace?.sessionId ?? null,
traceId,
outcome: "ok",
diagnostic: { code: "trace_events_auto_read_skip", reason, source: "trace-hydration", afterProjectedSeq, limit: runtimePolicy.traceHydrationPageLimit, valuesRedacted: true }
diagnostic: { code: "trace_events_auto_read_skip", reason, source: "trace-detail-read", afterProjectedSeq, limit: runtimePolicy.traceDetailPageLimit, valuesRedacted: true }
});
}
async function fetchTraceHydrationPage(traceId: string, afterProjectedSeq: number, options: { force?: boolean } = {}): Promise<ApiResult<AgentChatResultResponse>> {
async function fetchTraceDetailEventsPage(traceId: string, afterProjectedSeq: number, options: { force?: boolean } = {}): Promise<ApiResult<AgentChatResultResponse>> {
return fetchWorkbenchTraceEvents(traceId, afterProjectedSeq, shouldUseActivityTimeoutForTrace(traceId), { force: options.force });
}
function hydrateTerminalTraceGaps(source: ChatMessage[], reason: string): void {
function readTerminalTraceDetailGaps(source: ChatMessage[], reason: string): void {
// Terminal trace rows are loaded on explicit force/detail paths. Auto-filling
// historical terminal traces makes multi-turn Workbench sessions main-thread bound.
void source;
void reason;
}
async function hydrateTraceEvents(source: ChatMessage[] = messages.value, options: { force?: boolean } = {}): Promise<void> {
const candidates = traceHydrationCandidates(source);
async function readTraceEventsForMessages(source: ChatMessage[] = messages.value, options: { force?: boolean } = {}): Promise<void> {
const candidates = traceDetailReadCandidates(source);
if (options.force !== true) {
if (candidates.length > 0) {
const traceId = firstNonEmptyString(candidates[0]?.traceId, candidates[0]?.runnerTrace?.traceId);
recordWorkbenchRuntimeDiagnostic({ module: "workbench-trace-events-read", sessionId: candidates[0]?.sessionId ?? candidates[0]?.runnerTrace?.sessionId ?? null, traceId: traceId ?? null, outcome: "ok", diagnostic: { code: "trace_events_auto_read_disabled", reason: "sync-replay-authority", source: "trace-hydration", candidateCount: candidates.length, valuesRedacted: true } });
recordWorkbenchRuntimeDiagnostic({ module: "workbench-trace-events-read", sessionId: candidates[0]?.sessionId ?? candidates[0]?.runnerTrace?.sessionId ?? null, traceId: traceId ?? null, outcome: "ok", diagnostic: { code: "trace_events_auto_read_disabled", reason: "sync-replay-authority", source: "trace-detail-read", candidateCount: candidates.length, valuesRedacted: true } });
}
return;
}
for (const message of candidates) void hydrateTraceEventsForMessage(message, { force: true });
for (const message of candidates) void readTraceEventsForMessage(message, { force: true });
}
function traceHydrationCandidates(source: ChatMessage[]): ChatMessage[] {
return source.filter(messageNeedsTraceHydration).slice(-runtimePolicy.traceHydrationAutoQueueLimit).reverse();
function traceDetailReadCandidates(source: ChatMessage[]): ChatMessage[] {
return source.filter(messageNeedsTraceDetailRead).slice(-runtimePolicy.traceDetailAutoQueueLimit).reverse();
}
function messagesWithTraceAuthority(source: ChatMessage[]): ChatMessage[] {
@@ -989,7 +989,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
reduceServerState({ type: "trace.snapshot", traceId, trace: nextTrace });
}
function applyTraceHydrationResult(traceId: string, result: AgentChatResultResponse): void {
function applyTraceDetailEventsResult(traceId: string, result: AgentChatResultResponse): void {
const authoritySessionId = traceResultSessionId(result);
const ownerSessionId = traceOwnerSessionId(traceId, authoritySessionId);
if (!ownerSessionId) return;
@@ -1000,7 +1000,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (turnResultIsTerminalForMerge(result)) {
const terminalSeal = terminalSealResultWithoutTraceEvents(result);
rememberTurnStatus(traceId, terminalSeal);
projectTurnAuthorityToMessages(traceId, terminalSeal, "trace-hydration-terminal-seal");
projectTurnAuthorityToMessages(traceId, terminalSeal, "trace-detail-read-terminal-seal");
}
updateSessionMessages(ownerSessionId, (source) => source.map((message) => {
if (!messageMatchesTraceAuthority(message, traceId, authoritySessionId, ownerSessionId)) return message;
@@ -1050,15 +1050,15 @@ export const useWorkbenchStore = defineStore("workbench", () => {
}));
markWorkbenchTraceProjected(traceId);
if (traceResultHasTerminalEvidence(result) && !turnResultIsTerminalForMerge(result)) {
void refreshTerminalTraceFromSyncReplay(traceId, "trace-hydration-terminal-evidence");
void refreshTerminalTraceFromSyncReplay(traceId, "trace-detail-read-terminal-evidence");
}
if (turnResultIsTerminalForMerge(result)) {
rememberTurnStatus(traceId, result);
if (ownerSessionId === activeSessionId.value) {
chatPending.value = false;
currentRequest.value = null;
void clearActiveTrace(traceId, "trace-hydration-terminal");
restartRealtime("trace-hydration-terminal");
void clearActiveTrace(traceId, "trace-detail-read-terminal");
restartRealtime("trace-detail-read-terminal");
}
}
}
@@ -1477,7 +1477,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const sessionId = normalizeWorkbenchSessionId(record.sessionId);
if (!sessionId || sessionId !== activeSessionId.value) return;
const traceId = firstNonEmptyString(record.traceId);
if (traceId && messages.value.some((message) => firstNonEmptyString(message.traceId, message.runnerTrace?.traceId) === traceId)) return;
void refreshRealtimeSessionFromSyncReplay(sessionId, `cross-tab-session-projection:${firstNonEmptyString(record.reason, traceId, "session") ?? "session"}`);
}
@@ -1537,7 +1536,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (options.forceRead) void refreshMessageProjectionForTrace(ownerSessionId, traceId, { force: true });
else scheduleActiveTraceSyncReplay(traceId, "complete-trace-sync-replay", 0);
}
if (options.forceRead && terminalMessage && !traceProjectionIsTerminalSealed(traceId, ownerMessages)) void hydrateTraceEventsForMessage(terminalMessage, { force: true });
if (options.forceRead && terminalMessage && !traceProjectionIsTerminalSealed(traceId, ownerMessages)) void readTraceEventsForMessage(terminalMessage, { force: true });
if (ownerSessionId === activeSessionId.value) {
chatPending.value = false;
currentRequest.value = null;
@@ -1550,7 +1549,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
async function hydrateTerminalMessageDiagnostics(): Promise<void> {
const targets = messages.value.filter(messageNeedsTerminalDiagnostics).slice(-6);
void targets;
void hydrateTraceEvents(messages.value);
void readTraceEventsForMessages(messages.value);
}
function applyTerminalResultDiagnostics(traceId: string, result: AgentChatResultResponse): void {
@@ -1572,7 +1571,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const projection = clearCompletedDiagnostics ? nonBlockingProjection(resultProjection) : resultProjection ?? (terminal ? null : runnerTrace.projection ?? message.projection ?? null);
return { ...message, ...messageTimingPatchForMerge(message, result), ...messageStatusPatchForTerminalMerge(message, resultStatus, terminal), title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
}));
void hydrateTraceEvents(serverState.value.messagesBySessionId[ownerSessionId] ?? []);
void readTraceEventsForMessages(serverState.value.messagesBySessionId[ownerSessionId] ?? []);
scheduleSessionListRefresh(ownerSessionId, runtimePolicy.sessionListTerminalRefreshDelayMs);
}
@@ -1649,7 +1648,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
hasMore: false,
truncated: false,
nextProjectedSeq: 1,
range: { afterProjectedSeq: 0, fromProjectedSeq: 1, toProjectedSeq: 1, limit: runtimePolicy.traceHydrationPageLimit, returned: 1, total: 1 },
range: { afterProjectedSeq: 0, fromProjectedSeq: 1, toProjectedSeq: 1, limit: runtimePolicy.traceDetailPageLimit, returned: 1, total: 1 },
lastEventLabel: "workbench:admission:failed",
error: input.error ?? undefined,
projection: input.projection,
@@ -1832,7 +1831,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
currentRequest.value = null;
void hydrateTurnStatusAuthority(messages.value);
void hydrateTerminalMessageDiagnostics();
void hydrateTraceEvents(messages.value);
void readTraceEventsForMessages(messages.value);
reattachRestoredActiveTrace();
restartRealtime("apply-selected-session");
}
@@ -1891,7 +1890,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
}
function reattachRestoredActiveTrace(): void {
void hydrateTraceEvents(messages.value);
void readTraceEventsForMessages(messages.value);
const traceId = activeTraceIdFromMessages(messages.value, turnStatusAuthority.value);
if (traceId) reattachTrace(traceId);
}
+16
View File
@@ -16,14 +16,30 @@ declare global {
runtimePolicy?: {
defaultCodeAgentTimeoutMs?: number;
defaultGatewayTimeoutMs?: number;
traceDetailPageLimit?: number;
traceDetailMaxPages?: number;
traceDetailMaxAttempts?: number;
traceDetailRetryDelayMs?: number;
traceDetailAutoQueueLimit?: number;
traceDetailBackgroundConcurrency?: number;
traceDetailBackgroundDelayMs?: number;
/** @deprecated Use traceDetailPageLimit. */
traceHydrationPageLimit?: number;
/** @deprecated Use traceDetailMaxPages. */
traceHydrationMaxPages?: number;
/** @deprecated Use traceDetailMaxAttempts. */
traceHydrationMaxAttempts?: number;
/** @deprecated Use traceDetailRetryDelayMs. */
traceHydrationRetryDelayMs?: number;
/** @deprecated Use traceDetailAutoQueueLimit. */
traceHydrationAutoQueueLimit?: number;
/** @deprecated Use traceDetailBackgroundConcurrency. */
traceHydrationBackgroundConcurrency?: number;
/** @deprecated Use traceDetailBackgroundDelayMs. */
traceHydrationBackgroundDelayMs?: number;
sessionListPageLimit?: number;
workbenchDetailReadConcurrency?: number;
/** @deprecated Use workbenchDetailReadConcurrency. */
workbenchReadHydrationConcurrency?: number;
workbenchReadFailureCooldownMs?: number;
workbenchTurnStatusMinRefreshMs?: number;